初始化
This commit is contained in:
6
.eslintignore
Normal file
6
.eslintignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
src/public/**/*.js
|
||||||
|
vendor/**/*.js
|
||||||
|
vendor/**/*.ts
|
||||||
|
src/app/Util.ts
|
||||||
|
*.js
|
||||||
|
typings/**/*.d.ts
|
||||||
22
.eslintrc
Normal file
22
.eslintrc
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:prettier/recommended",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"progress",
|
||||||
|
"@typescript-eslint",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2020,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"progress/activate": 1,
|
||||||
|
"import/no-absolute-path": "off"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
]
|
||||||
|
}
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
|
/build
|
||||||
|
/.idea
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 4
|
||||||
|
}
|
||||||
19
LICENSE
Normal file
19
LICENSE
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (C) 2021 by Netris, JSC.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
227
README.md
Normal file
227
README.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# ws scrcpy
|
||||||
|
|
||||||
|
Web client for [Genymobile/scrcpy][scrcpy] and more.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Browser must support the following technologies:
|
||||||
|
* WebSockets
|
||||||
|
* Media Source Extensions and h264 decoding;
|
||||||
|
* WebWorkers
|
||||||
|
* WebAssembly
|
||||||
|
|
||||||
|
Server:
|
||||||
|
* Node.js v10+
|
||||||
|
* node-gyp ([installation](https://github.com/nodejs/node-gyp#installation))
|
||||||
|
* `adb` executable must be available in the PATH environment variable
|
||||||
|
|
||||||
|
Device:
|
||||||
|
* Android 5.0+ (API 21+)
|
||||||
|
* Enabled [adb debugging](https://developer.android.com/studio/command-line/adb.html#Enabling)
|
||||||
|
* On some devices, you also need to enable
|
||||||
|
[an additional option](https://github.com/Genymobile/scrcpy/issues/70#issuecomment-373286323)
|
||||||
|
to control it using keyboard and mouse.
|
||||||
|
|
||||||
|
## Build and Start
|
||||||
|
|
||||||
|
Make sure you have installed [node.js](https://nodejs.org/en/download/),
|
||||||
|
[node-gyp](https://github.com/nodejs/node-gyp) and
|
||||||
|
[build tools](https://github.com/nodejs/node-gyp#installation)
|
||||||
|
```shell
|
||||||
|
git clone https://github.com/NetrisTV/ws-scrcpy.git
|
||||||
|
cd ws-scrcpy
|
||||||
|
|
||||||
|
## For stable version find latest tag and switch to it:
|
||||||
|
# git tag -l
|
||||||
|
# git checkout vX.Y.Z
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported features
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
#### Screen casting
|
||||||
|
The modified [version][fork] of [Genymobile/scrcpy][scrcpy] used to stream
|
||||||
|
H264-video, which then decoded by one of included decoders:
|
||||||
|
|
||||||
|
##### Mse Player
|
||||||
|
|
||||||
|
Based on [xevokk/h264-converter][xevokk/h264-converter].
|
||||||
|
HTML5 Video.<br>
|
||||||
|
Requires [Media Source API][MSE] and `video/mp4; codecs="avc1.42E01E"`
|
||||||
|
[support][isTypeSupported]. Creates mp4 containers from NALU, received from a
|
||||||
|
device, then feeds them to [MediaSource][MediaSource]. In theory, it can use
|
||||||
|
hardware acceleration.
|
||||||
|
|
||||||
|
##### Broadway Player
|
||||||
|
|
||||||
|
Based on [mbebenita/Broadway][broadway] and
|
||||||
|
[131/h264-live-player][h264-live-player].<br>
|
||||||
|
Software video-decoder compiled into wasm-module.
|
||||||
|
Requires [WebAssembly][wasm] and preferably [WebGL][webgl] support.
|
||||||
|
|
||||||
|
##### TinyH264 Player
|
||||||
|
|
||||||
|
Based on [udevbe/tinyh264][tinyh264].<br>
|
||||||
|
Software video-decoder compiled into wasm-module. A slightly updated version of
|
||||||
|
[mbebenita/Broadway][broadway].
|
||||||
|
Requires [WebAssembly][wasm], [WebWorkers][workers], [WebGL][webgl] support.
|
||||||
|
|
||||||
|
##### WebCodecs Player
|
||||||
|
|
||||||
|
Decoding is done by browser built-in (software/hardware) media decoder.
|
||||||
|
Requires [WebCodecs][webcodecs] support. At the moment, available only in
|
||||||
|
[Chromium](https://www.chromestatus.com/feature/5669293909868544) and derivatives.
|
||||||
|
|
||||||
|
#### Remote control
|
||||||
|
* Touch events (including multi-touch)
|
||||||
|
* Multi-touch emulation: <kbd>CTRL</kbd> to start with center at the center of
|
||||||
|
the screen, <kbd>SHIFT</kbd> + <kbd>CTRL</kbd> to start with center at the
|
||||||
|
current point
|
||||||
|
* Mouse wheel and touchpad vertical/horizontal scrolling
|
||||||
|
* Capturing keyboard events
|
||||||
|
* Injecting text (ASCII only)
|
||||||
|
* Copy to/from device clipboard
|
||||||
|
* Device "rotation"
|
||||||
|
|
||||||
|
#### File push
|
||||||
|
Drag & drop an APK file to push it to the `/data/local/tmp` directory. You can
|
||||||
|
install it manually from the included [xtermjs/xterm.js][xterm.js] terminal
|
||||||
|
emulator (see below).
|
||||||
|
|
||||||
|
#### Remote shell
|
||||||
|
Control your device from `adb shell` in your browser.
|
||||||
|
|
||||||
|
#### Debug WebPages/WebView
|
||||||
|
[/docs/Devtools.md](/docs/Devtools.md)
|
||||||
|
|
||||||
|
#### File listing
|
||||||
|
* List files
|
||||||
|
* Upload files by drag & drop
|
||||||
|
* Download files
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
|
||||||
|
***Experimental Feature***: *is not built by default*
|
||||||
|
(see [custom build](#custom-build))
|
||||||
|
|
||||||
|
#### Screen Casting
|
||||||
|
|
||||||
|
Requires [ws-qvh][ws-qvh] available in `PATH`.
|
||||||
|
|
||||||
|
#### MJPEG Server
|
||||||
|
|
||||||
|
Enable `USE_WDA_MJPEG_SERVER` in the build configuration file
|
||||||
|
(see [custom build](#custom-build)).
|
||||||
|
|
||||||
|
Alternative way to stream screen content. It does not
|
||||||
|
require additional software as `ws-qvh`, but may require more resources as each
|
||||||
|
frame encoded as jpeg image.
|
||||||
|
|
||||||
|
#### Remote control
|
||||||
|
|
||||||
|
To control device we use [appium/WebDriverAgent][WebDriverAgent].
|
||||||
|
Functionality limited to:
|
||||||
|
* Simple touch
|
||||||
|
* Scroll
|
||||||
|
* Home button click
|
||||||
|
|
||||||
|
Make sure you did properly [setup WebDriverAgent](https://appium.io/docs/en/drivers/ios-xcuitest-real-devices/).
|
||||||
|
WebDriverAgent project is located under `node_modules/appium-webdriveragent/`.
|
||||||
|
|
||||||
|
You might want to enable `AssistiveTouch` on your device: `Settings/General/Accessibility`.
|
||||||
|
|
||||||
|
## Custom Build
|
||||||
|
|
||||||
|
You can customize project before build by overriding the
|
||||||
|
[default configuration](/webpack/default.build.config.json) in
|
||||||
|
[build.config.override.json](/build.config.override.json):
|
||||||
|
* `INCLUDE_APPL` - include code for iOS device tracking and control
|
||||||
|
* `INCLUDE_GOOG` - include code for Android device tracking and control
|
||||||
|
* `INCLUDE_ADB_SHELL` - [remote shell](#remote-shell) for android devices
|
||||||
|
([xtermjs/xterm.js][xterm.js], [Tyriar/node-pty][node-pty])
|
||||||
|
* `INCLUDE_DEV_TOOLS` - [dev tools](#debug-webpageswebview) for web pages and
|
||||||
|
web views on android devices
|
||||||
|
* `INCLUDE_FILE_LISTING` - minimalistic [file management](#file-listing)
|
||||||
|
* `USE_BROADWAY` - include [Broadway Player](#broadway-player)
|
||||||
|
* `USE_H264_CONVERTER` - include [Mse Player](#mse-player)
|
||||||
|
* `USE_TINY_H264` - include [TinyH264 Player](#tinyh264-player)
|
||||||
|
* `USE_WEBCODECS` - include [WebCodecs Player](#webcodecs-player)
|
||||||
|
* `USE_WDA_MJPEG_SERVER` - configure WebDriverAgent to start MJPEG server
|
||||||
|
* `USE_QVH_SERVER` - include support for [ws-qvh][ws-qvh]
|
||||||
|
* `SCRCPY_LISTENS_ON_ALL_INTERFACES` - WebSocket server in `scrcpy-server.jar`
|
||||||
|
will listen for connections on all available interfaces. When `true`, it allows
|
||||||
|
connecting to device directly from a browser. Otherwise, the connection must be
|
||||||
|
established over adb.
|
||||||
|
|
||||||
|
## Run configuration
|
||||||
|
|
||||||
|
You can specify a path to a configuration file in `WS_SCRCPY_CONFIG`
|
||||||
|
environment variable.
|
||||||
|
|
||||||
|
If you want to have another pathname than "/" you can specify it in the
|
||||||
|
`WS_SCRCPY_PATHNAME` environment variable.
|
||||||
|
|
||||||
|
Configuration file format: [Configuration.d.ts](/src/types/Configuration.d.ts).
|
||||||
|
|
||||||
|
Configuration file example: [config.example.yaml](/config.example.yaml).
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
* The server on the Android Emulator listens on the internal interface and not
|
||||||
|
available from the outside. Select `proxy over adb` from the interfaces list.
|
||||||
|
* TinyH264Player may fail to start, try to reload the page.
|
||||||
|
* MsePlayer reports too many dropped frames in quality statistics: needs
|
||||||
|
further investigation.
|
||||||
|
* On Safari file upload does not show progress (it works in one piece).
|
||||||
|
|
||||||
|
## Security warning
|
||||||
|
Be advised and keep in mind:
|
||||||
|
* There is no encryption between browser and node.js server (you can [configure](#run-configuration) HTTPS).
|
||||||
|
* There is no encryption between browser and WebSocket server on android device.
|
||||||
|
* There is no authorization on any level.
|
||||||
|
* The modified version of scrcpy with integrated WebSocket server is listening
|
||||||
|
for connections on all network interfaces (see [custom build](#custom-build)).
|
||||||
|
* The modified version of scrcpy will keep running after the last client
|
||||||
|
disconnected.
|
||||||
|
|
||||||
|
## Related projects
|
||||||
|
* [Genymobile/scrcpy][scrcpy]
|
||||||
|
* [xevokk/h264-converter][xevokk/h264-converter]
|
||||||
|
* [131/h264-live-player][h264-live-player]
|
||||||
|
* [mbebenita/Broadway][broadway]
|
||||||
|
* [DeviceFarmer/adbkit][adbkit]
|
||||||
|
* [xtermjs/xterm.js][xterm.js]
|
||||||
|
* [udevbe/tinyh264][tinyh264]
|
||||||
|
* [danielpaulus/quicktime_video_hack][qvh]
|
||||||
|
|
||||||
|
## scrcpy websocket fork
|
||||||
|
|
||||||
|
Currently, support of WebSocket protocol added to v1.19 of scrcpy
|
||||||
|
* [Prebuilt package](/vendor/Genymobile/scrcpy/scrcpy-server.jar)
|
||||||
|
* [Source code][fork]
|
||||||
|
|
||||||
|
[fork]: https://github.com/NetrisTV/scrcpy/tree/feature/websocket-v1.19.x
|
||||||
|
|
||||||
|
[scrcpy]: https://github.com/Genymobile/scrcpy
|
||||||
|
[xevokk/h264-converter]: https://github.com/xevokk/h264-converter
|
||||||
|
[h264-live-player]: https://github.com/131/h264-live-player
|
||||||
|
[broadway]: https://github.com/mbebenita/Broadway
|
||||||
|
[adbkit]: https://github.com/DeviceFarmer/adbkit
|
||||||
|
[xterm.js]: https://github.com/xtermjs/xterm.js
|
||||||
|
[tinyh264]: https://github.com/udevbe/tinyh264
|
||||||
|
[node-pty]: https://github.com/Tyriar/node-pty
|
||||||
|
[WebDriverAgent]: https://github.com/appium/WebDriverAgent
|
||||||
|
[qvh]: https://github.com/danielpaulus/quicktime_video_hack
|
||||||
|
[ws-qvh]: https://github.com/NetrisTV/ws-qvh
|
||||||
|
|
||||||
|
[MSE]: https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API
|
||||||
|
[isTypeSupported]: https://developer.mozilla.org/en-US/docs/Web/API/MediaSource/isTypeSupported
|
||||||
|
[MediaSource]: https://developer.mozilla.org/en-US/docs/Web/API/MediaSource
|
||||||
|
[wasm]: https://developer.mozilla.org/en-US/docs/WebAssembly
|
||||||
|
[webgl]: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API
|
||||||
|
[workers]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
|
||||||
|
[webcodecs]: https://w3c.github.io/webcodecs/
|
||||||
4
SECURITY.md
Normal file
4
SECURITY.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Reporting Security Issues
|
||||||
|
|
||||||
|
If you discover a security issue, please report it by sending an
|
||||||
|
email to [drauggres@gmail.com](mailto:drauggres@gmail.com).
|
||||||
1
build.config.override.json
Normal file
1
build.config.override.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
40
config.example.yaml
Normal file
40
config.example.yaml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Run configuration example. See full config file spec in src/types/Configuration.d.ts
|
||||||
|
|
||||||
|
# Device trackers
|
||||||
|
## track android devices (default: true, if was INCLUDE_GOOG enabled in build config)
|
||||||
|
runGoogTracker: false
|
||||||
|
## track iOS devices (default: true, if was INCLUDE_APPL enabled in build config)
|
||||||
|
runApplTracker: false;
|
||||||
|
|
||||||
|
# HTTP[s] servers configuration
|
||||||
|
server:
|
||||||
|
- secure: false
|
||||||
|
port: 8000
|
||||||
|
redirectToSecure:
|
||||||
|
port: 8443
|
||||||
|
host: first-mobile-stand.example.com
|
||||||
|
- secure: true
|
||||||
|
port: 8443
|
||||||
|
options:
|
||||||
|
certPath: /Users/example/ssl/STAR_example_com.crt
|
||||||
|
keyPath: /Users/example/ssl/STAR_example_com.key
|
||||||
|
|
||||||
|
# Announce remote device trackers. The server doesn't check their availability.
|
||||||
|
remoteHostList:
|
||||||
|
- useProxy: true # optional, default: false
|
||||||
|
type: android # required, "android" | "ios" | ["android", "ios"]
|
||||||
|
secure: true # required, false for HTTP, true for HTTPS
|
||||||
|
hostname: second-mobile-stand.example.com
|
||||||
|
port: 8443
|
||||||
|
- useProxy: true
|
||||||
|
type: ios
|
||||||
|
secure: true
|
||||||
|
hostname: second-mobile-stand.example.com
|
||||||
|
port: 8443
|
||||||
|
- useProxy: true
|
||||||
|
type: # short variant
|
||||||
|
- ios
|
||||||
|
- android
|
||||||
|
secure: true
|
||||||
|
hostname: third-mobile-stand.example.com
|
||||||
|
port: 8443
|
||||||
33
docs/Devtools.md
Normal file
33
docs/Devtools.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Devtools
|
||||||
|
Forward and proxy a WebKit debug-socket from an android device to your browser
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### Server
|
||||||
|
1. Find devtools sockets: `adb shell 'grep -a devtools_remote /proc/net/unix'`
|
||||||
|
2. For each socket request `/json` and `/json/version`
|
||||||
|
3. Replace websocket address in response with our hostname
|
||||||
|
4. Combine all data and send to a client
|
||||||
|
|
||||||
|
### Client
|
||||||
|
Though each debuggable page explicitly specifies `devtoolsFrontendUrl` it is
|
||||||
|
possible that provided version of devtools frontend will not work in your
|
||||||
|
browser. To ensure that you will be able to debug webpage/webview, client
|
||||||
|
creates three links:
|
||||||
|
- `inspect` - this is a link provided by a remote browser in the answer for
|
||||||
|
`/json` request (only WebSocket address is changed). When this link points to
|
||||||
|
a local version of devtools (bundled with debuggable browser) you will not able
|
||||||
|
to open it, because only WebSocket forwarding is implemented at the moment.
|
||||||
|
- `bundled` - link to a version of devtools bundled with your (chromium based)
|
||||||
|
browser without specifying revision or version of the remote target. You will
|
||||||
|
get same link in the `chrome://inspect` page of Chromium browser.
|
||||||
|
e.g. `devtools://devtools/bundled/inspector.html?ws=<WebSocketAddress>`
|
||||||
|
- `remote` - link to a bundled devtools but with specified revision and version
|
||||||
|
of remote target. This link is visible only when original link in
|
||||||
|
`devtoolsFrontendUrl` contains revision. You will get same link in the
|
||||||
|
`chrome://inspect` page of Chrome browser.
|
||||||
|
e.g. `devtools://devtools/remote/serve_rev/@<Revision>/inspector.html?remoteVersion=<Version>&remoteFrontend=true&ws=<WebSocketAddress>`
|
||||||
|
|
||||||
|
**You can't open two last links with click or `open link in new tab`.**
|
||||||
|
|
||||||
|
You must copy link and open it manually. This is browser restriction.
|
||||||
46
docs/debug.md
Normal file
46
docs/debug.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
### Client
|
||||||
|
|
||||||
|
1. Build dev version (will include source maps):
|
||||||
|
> npm run dist:dev
|
||||||
|
|
||||||
|
2. Run from `dist` directory:
|
||||||
|
> npm run start
|
||||||
|
|
||||||
|
3. Use the browser's built-in developer tools or your favorite IDE.
|
||||||
|
|
||||||
|
### Node.js server
|
||||||
|
|
||||||
|
1. `npm run dist:dev`
|
||||||
|
2. `cd dist`
|
||||||
|
3. `node --inspect-brk ./index.js`
|
||||||
|
|
||||||
|
__HINT__: you might want to set `DEBUG` environment variable (see [debug](https://github.com/visionmedia/debug)):
|
||||||
|
> DEBUG=* node --inspect-brk ./index.js
|
||||||
|
|
||||||
|
### Android server (`scrcpy-server.jar`)
|
||||||
|
|
||||||
|
Source code is available [here](https://github.com/NetrisTV/scrcpy/tree/feature/websocket-server)
|
||||||
|
__HINT__: you might want to build a dev version.
|
||||||
|
|
||||||
|
To debug the server:
|
||||||
|
1. start node server
|
||||||
|
2. kill server from UI (click button with cross and PID number).
|
||||||
|
3. upload server package to a device:
|
||||||
|
> adb push server/build/outputs/apk/debug/server-debug.apk /data/local/tmp/scrcpy-server.jar
|
||||||
|
|
||||||
|
4. setup port forwarding:
|
||||||
|
> adb forward tcp:5005 tcp:5005
|
||||||
|
|
||||||
|
5. connect to device with adb shell:
|
||||||
|
> adb shell
|
||||||
|
|
||||||
|
6.1. for Android 8 and below run this in adb shell (single line):
|
||||||
|
> CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process -agentlib:jdwp=transport=dt_socket,suspend=y,server=y,address=5005 / com.genymobile.scrcpy.Server 1.17-ws5 DEBUG web 8886
|
||||||
|
|
||||||
|
6.2. for Android 9 and above:
|
||||||
|
> CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process -XjdwpProvider:internal -XjdwpOptions:transport=dt_socket,suspend=y,server=y,address=5005 / com.genymobile.scrcpy.Server 1.17-ws5 web DEBUG 8886
|
||||||
|
|
||||||
|
7. Open project (scrcpy, not ws-scrcpy) in Android Studio, create `Remote` Debug configuration with:
|
||||||
|
> Host: localhost, Port: 5005
|
||||||
|
|
||||||
|
Connect the debugger to the remote server on the device.
|
||||||
30
docs/scheme.md
Normal file
30
docs/scheme.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
```
|
||||||
|
+--------------------------+ +------------------------------+
|
||||||
|
| Android device | | Server |
|
||||||
|
| | | |
|
||||||
|
| +----------------------+ | | +--------------------------+ |
|
||||||
|
| | adb | | Run scrcpy | | adb (client) | |
|
||||||
|
| | |<---------------| | |
|
||||||
|
| | (usb/tcp) | | | | | |
|
||||||
|
| +----------------------+ | | +--------------------------+ |
|
||||||
|
| | | |
|
||||||
|
| +----------------------+ | | +--------------------------+ |
|
||||||
|
| | scrcpy | | | | nodejs | |
|
||||||
|
| | | | | | | |
|
||||||
|
----| (ws://0.0.0.0:8886/) | | | | (http://0.0.0.0:8000/) |----
|
||||||
|
| | +----------------------+ | | +--------------------------+ | |
|
||||||
|
| +--------------------------+ +------------------------------+ |
|
||||||
|
| |
|
||||||
|
| |
|
||||||
|
| |
|
||||||
|
| |
|
||||||
|
| |
|
||||||
|
| HTTP: |
|
||||||
|
| +------------------------------+ < static (html, js...)|
|
||||||
|
|Web-socket: | Client | |
|
||||||
|
|< Input events | | Web-socket: |
|
||||||
|
|> Video stream | +--------------------------+ | < Device list |
|
||||||
|
-------------------| Web-browser |-----------------------------
|
||||||
|
| +--------------------------+ |
|
||||||
|
+------------------------------+
|
||||||
|
```
|
||||||
9411
package-lock.json
generated
Normal file
9411
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
77
package.json
Normal file
77
package.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "ws-scrcpy",
|
||||||
|
"version": "0.9.0-dev",
|
||||||
|
"description": "Web client for scrcpy and more",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "npx rimraf dist",
|
||||||
|
"dist:dev": "webpack --config webpack/ws-scrcpy.dev.ts --stats-error-details",
|
||||||
|
"dist:prod": "webpack --config webpack/ws-scrcpy.prod.ts --stats-error-details",
|
||||||
|
"dist": "npm run dist:prod",
|
||||||
|
"start": "npm run dist && cd dist && npm start",
|
||||||
|
"script:dist:start": "node ./index.js",
|
||||||
|
"lint": "eslint src/ --ext .ts",
|
||||||
|
"format": "eslint src/ --fix --ext .ts",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "Sergey Volkov <drauggres@gmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dead50f7/adbkit": "^2.11.4",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"ios-device-lib": "^0.9.2",
|
||||||
|
"node-mjpeg-proxy": "^0.3.2",
|
||||||
|
"node-pty": "^0.10.1",
|
||||||
|
"portfinder": "^1.0.28",
|
||||||
|
"python-shell": "^4.0.0",
|
||||||
|
"tslib": "^2.3.1",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"xml2js": "^0.6.2",
|
||||||
|
"yaml": "^2.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dead50f7/generate-package-json-webpack-plugin": "^2.6.1",
|
||||||
|
"@types/bluebird": "^3.5.36",
|
||||||
|
"@types/dom-webcodecs": "^0.1.3",
|
||||||
|
"@types/express": "^4.17.13",
|
||||||
|
"@types/node": "^12.20.47",
|
||||||
|
"@types/node-forge": "^0.10.0",
|
||||||
|
"@types/npmlog": "^4.1.4",
|
||||||
|
"@types/webpack-node-externals": "^2.5.3",
|
||||||
|
"@types/ws": "^7.4.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||||
|
"@typescript-eslint/parser": "^5.18.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"css-loader": "^6.8.1",
|
||||||
|
"eslint": "^8.12.0",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
|
"eslint-plugin-progress": "0.0.1",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
|
"h264-converter": "^0.1.4",
|
||||||
|
"html-webpack-plugin": "^5.5.0",
|
||||||
|
"ifdef-loader": "^2.3.2",
|
||||||
|
"mini-css-extract-plugin": "^2.6.1",
|
||||||
|
"mkdirp": "^1.0.4",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
|
"prettier": "^2.6.2",
|
||||||
|
"recursive-copy": "^2.0.14",
|
||||||
|
"rimraf": "^3.0.0",
|
||||||
|
"svg-inline-loader": "^0.8.2",
|
||||||
|
"sylvester.js": "^0.1.1",
|
||||||
|
"tinyh264": "^0.0.7",
|
||||||
|
"ts-loader": "^9.3.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "^4.7.4",
|
||||||
|
"webpack": "^5.94.0",
|
||||||
|
"webpack-cli": "^4.10.0",
|
||||||
|
"webpack-node-externals": "^2.5.2",
|
||||||
|
"worker-loader": "^3.0.8",
|
||||||
|
"xterm": "^4.5.0",
|
||||||
|
"xterm-addon-attach": "^0.6.0",
|
||||||
|
"xterm-addon-fit": "^0.5.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"appium-xcuitest-driver": "^3.62.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app/Attribute.ts
Normal file
14
src/app/Attribute.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const Attribute = {
|
||||||
|
COMMAND: 'data-command',
|
||||||
|
FULL_NAME: 'data-full-name',
|
||||||
|
NAME: 'data-name',
|
||||||
|
PID: 'data-pid',
|
||||||
|
UDID: 'data-udid',
|
||||||
|
URL: 'data-url',
|
||||||
|
USE_PROXY: 'data-use-proxy',
|
||||||
|
SECURE: 'data-secure',
|
||||||
|
HOSTNAME: 'data-hostname',
|
||||||
|
PORT: 'data-port',
|
||||||
|
PATHNAME: 'data-pathname',
|
||||||
|
VALUE: 'data-value',
|
||||||
|
};
|
||||||
61
src/app/DisplayInfo.ts
Normal file
61
src/app/DisplayInfo.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import Size from './Size';
|
||||||
|
|
||||||
|
export class DisplayInfo {
|
||||||
|
public static readonly DEFAULT_DISPLAY = 0x00000000;
|
||||||
|
public static readonly FLAG_ROUND = 0b10000;
|
||||||
|
public static readonly FLAG_PRESENTATION = 0b1000;
|
||||||
|
public static readonly FLAG_PRIVATE = 0b100;
|
||||||
|
public static readonly FLAG_SECURE = 0b10;
|
||||||
|
public static readonly FLAG_SUPPORTS_PROTECTED_BUFFERS = 0b1;
|
||||||
|
public static readonly INVALID_DISPLAY = -1;
|
||||||
|
public static readonly BUFFER_LENGTH = 24;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly displayId: number,
|
||||||
|
public readonly size: Size,
|
||||||
|
public readonly rotation: number,
|
||||||
|
public readonly layerStack: number,
|
||||||
|
public readonly flags: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public toBuffer(): Buffer {
|
||||||
|
const temp = Buffer.alloc(DisplayInfo.BUFFER_LENGTH);
|
||||||
|
let offset = 0;
|
||||||
|
offset = temp.writeInt32BE(this.displayId, offset);
|
||||||
|
offset = temp.writeInt32BE(this.size.width, offset);
|
||||||
|
offset = temp.writeInt32BE(this.size.height, offset);
|
||||||
|
offset = temp.writeInt32BE(this.rotation, offset);
|
||||||
|
offset = temp.writeInt32BE(this.layerStack, offset);
|
||||||
|
temp.writeInt32BE(this.flags, offset);
|
||||||
|
return temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
// prettier-ignore
|
||||||
|
return `DisplayInfo{displayId=${
|
||||||
|
this.displayId}, size=${
|
||||||
|
this.size}, rotation=${
|
||||||
|
this.rotation}, layerStack=${
|
||||||
|
this.layerStack}, flags=${
|
||||||
|
this.flags}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fromBuffer(buffer: Buffer): DisplayInfo {
|
||||||
|
if (buffer.length !== DisplayInfo.BUFFER_LENGTH) {
|
||||||
|
throw Error(`Incorrect buffer length. Expected: ${DisplayInfo.BUFFER_LENGTH}, received: ${buffer.length}`);
|
||||||
|
}
|
||||||
|
let offset = 0;
|
||||||
|
const displayId = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
const width = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
const height = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
const rotation = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
const layerStack = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
const flags = buffer.readInt32BE(offset);
|
||||||
|
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/app/ErrorHandler.ts
Normal file
3
src/app/ErrorHandler.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default class ErrorHandler {
|
||||||
|
constructor(readonly OnError: (ev: string | Event) => void) {}
|
||||||
|
}
|
||||||
19
src/app/MotionEvent.ts
Normal file
19
src/app/MotionEvent.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export default class MotionEvent {
|
||||||
|
public static ACTION_DOWN = 0;
|
||||||
|
public static ACTION_UP = 1;
|
||||||
|
public static ACTION_MOVE = 2;
|
||||||
|
/**
|
||||||
|
* Button constant: Primary button (left mouse button).
|
||||||
|
*/
|
||||||
|
public static BUTTON_PRIMARY: number = 1 << 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button constant: Secondary button (right mouse button).
|
||||||
|
*/
|
||||||
|
public static BUTTON_SECONDARY: number = 1 << 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button constant: Tertiary button (middle mouse button).
|
||||||
|
*/
|
||||||
|
public static BUTTON_TERTIARY: number = 1 << 2;
|
||||||
|
}
|
||||||
40
src/app/Point.ts
Normal file
40
src/app/Point.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export interface PointInterface {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Point {
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
constructor(x: number, y: number) {
|
||||||
|
this.x = Math.round(x);
|
||||||
|
this.y = Math.round(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals(o: Point): boolean {
|
||||||
|
if (this === o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.x === o.x && this.y === o.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public distance(to: Point): number {
|
||||||
|
const x = this.x - to.x;
|
||||||
|
const y = this.y - to.y;
|
||||||
|
return Math.sqrt(x * x + y * y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return `Point{x=${this.x}, y=${this.y}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): PointInterface {
|
||||||
|
return {
|
||||||
|
x: this.x,
|
||||||
|
y: this.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/Position.ts
Normal file
55
src/app/Position.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Point, { PointInterface } from './Point';
|
||||||
|
import Size, { SizeInterface } from './Size';
|
||||||
|
|
||||||
|
export interface PositionInterface {
|
||||||
|
point: PointInterface;
|
||||||
|
screenSize: SizeInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Position {
|
||||||
|
public constructor(readonly point: Point, readonly screenSize: Size) {}
|
||||||
|
|
||||||
|
public equals(o: Position): boolean {
|
||||||
|
if (this === o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.point.equals(o.point) && this.screenSize.equals(o.screenSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public rotate(rotation: number): Position {
|
||||||
|
switch (rotation) {
|
||||||
|
case 1:
|
||||||
|
return new Position(
|
||||||
|
new Point(this.screenSize.height - this.point.y, this.point.x),
|
||||||
|
this.screenSize.rotate(),
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return new Position(
|
||||||
|
new Point(this.screenSize.width - this.point.x, this.screenSize.height - this.point.y),
|
||||||
|
this.screenSize,
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return new Position(
|
||||||
|
new Point(this.point.y, this.screenSize.width - this.point.x),
|
||||||
|
this.screenSize.rotate(),
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return `Position{point=${this.point}, screenSize=${this.screenSize}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): PositionInterface {
|
||||||
|
return {
|
||||||
|
point: this.point.toJSON(),
|
||||||
|
screenSize: this.screenSize.toJSON(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/app/Rect.ts
Normal file
62
src/app/Rect.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
interface RectInterface {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Rect {
|
||||||
|
constructor(readonly left: number, readonly top: number, readonly right: number, readonly bottom: number) {
|
||||||
|
this.left = left;
|
||||||
|
this.top = top;
|
||||||
|
this.right = right;
|
||||||
|
this.bottom = bottom;
|
||||||
|
}
|
||||||
|
public static equals(a?: Rect | null, b?: Rect | null): boolean {
|
||||||
|
if (!a && !b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!a && !!b && a.equals(b);
|
||||||
|
}
|
||||||
|
public static copy(a?: Rect | null): Rect | null {
|
||||||
|
if (!a) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Rect(a.left, a.top, a.right, a.bottom);
|
||||||
|
}
|
||||||
|
public equals(o: Rect | null): boolean {
|
||||||
|
if (this === o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!o) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.left === o.left && this.top === o.top && this.right === o.right && this.bottom === o.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getWidth(): number {
|
||||||
|
return this.right - this.left;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHeight(): number {
|
||||||
|
return this.bottom - this.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
// prettier-ignore
|
||||||
|
return `Rect{left=${
|
||||||
|
this.left}, top=${
|
||||||
|
this.top}, right=${
|
||||||
|
this.right}, bottom=${
|
||||||
|
this.bottom}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): RectInterface {
|
||||||
|
return {
|
||||||
|
left: this.left,
|
||||||
|
right: this.right,
|
||||||
|
top: this.top,
|
||||||
|
bottom: this.bottom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/ScreenInfo.ts
Normal file
33
src/app/ScreenInfo.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Rect from './Rect';
|
||||||
|
import Size from './Size';
|
||||||
|
|
||||||
|
export default class ScreenInfo {
|
||||||
|
public static readonly BUFFER_LENGTH: number = 25;
|
||||||
|
constructor(readonly contentRect: Rect, readonly videoSize: Size, readonly deviceRotation: number) {}
|
||||||
|
|
||||||
|
public static fromBuffer(buffer: Buffer): ScreenInfo {
|
||||||
|
const left = buffer.readInt32BE(0);
|
||||||
|
const top = buffer.readInt32BE(4);
|
||||||
|
const right = buffer.readInt32BE(8);
|
||||||
|
const bottom = buffer.readInt32BE(12);
|
||||||
|
const width = buffer.readInt32BE(16);
|
||||||
|
const height = buffer.readInt32BE(20);
|
||||||
|
const deviceRotation = buffer.readUInt8(24);
|
||||||
|
return new ScreenInfo(new Rect(left, top, right, bottom), new Size(width, height), deviceRotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals(o?: ScreenInfo | null): boolean {
|
||||||
|
if (!o) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.contentRect.equals(o.contentRect) &&
|
||||||
|
this.videoSize.equals(o.videoSize) &&
|
||||||
|
this.deviceRotation === o.deviceRotation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return `ScreenInfo{contentRect=${this.contentRect}, videoSize=${this.videoSize}, deviceRotation=${this.deviceRotation}}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/app/Size.ts
Normal file
70
src/app/Size.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export interface SizeInterface {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Size {
|
||||||
|
public readonly w: number;
|
||||||
|
public readonly h: number;
|
||||||
|
|
||||||
|
constructor(readonly width: number, readonly height: number) {
|
||||||
|
this.w = width;
|
||||||
|
this.h = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static equals(a?: Size | null, b?: Size | null): boolean {
|
||||||
|
if (!a && !b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!a && !!b && a.equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static copy(a?: Size | null): Size | null {
|
||||||
|
if (!a) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Size(a.width, a.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
length(): number {
|
||||||
|
return this.w * this.h;
|
||||||
|
}
|
||||||
|
|
||||||
|
public rotate(): Size {
|
||||||
|
return new Size(this.height, this.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals(o: Size | null | undefined): boolean {
|
||||||
|
if (this === o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!o) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.width === o.width && this.height === o.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public intersect(o: Size | undefined | null): Size {
|
||||||
|
if (!o) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
const minH = Math.min(this.height, o.height);
|
||||||
|
const minW = Math.min(this.width, o.width);
|
||||||
|
return new Size(minW, minH);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHalfSize(): Size {
|
||||||
|
return new Size(this.width >>> 1, this.height >>> 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return `Size{width=${this.width}, height=${this.height}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): SizeInterface {
|
||||||
|
return {
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/app/UIEventsCode.ts
Normal file
191
src/app/UIEventsCode.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// https://w3c.github.io/uievents-code/
|
||||||
|
|
||||||
|
export default class UIEventsCode {
|
||||||
|
// 3.1.1.1. Writing System Keys
|
||||||
|
public static readonly Backquote: string = 'Backquote';
|
||||||
|
public static readonly Backslash: string = 'Backslash';
|
||||||
|
public static readonly BracketLeft: string = 'BracketLeft';
|
||||||
|
public static readonly BracketRight: string = 'BracketRight';
|
||||||
|
public static readonly Comma: string = 'Comma';
|
||||||
|
public static readonly Digit0: string = 'Digit0';
|
||||||
|
public static readonly Digit1: string = 'Digit1';
|
||||||
|
public static readonly Digit2: string = 'Digit2';
|
||||||
|
public static readonly Digit3: string = 'Digit3';
|
||||||
|
public static readonly Digit4: string = 'Digit4';
|
||||||
|
public static readonly Digit5: string = 'Digit5';
|
||||||
|
public static readonly Digit6: string = 'Digit6';
|
||||||
|
public static readonly Digit7: string = 'Digit7';
|
||||||
|
public static readonly Digit8: string = 'Digit8';
|
||||||
|
public static readonly Digit9: string = 'Digit9';
|
||||||
|
public static readonly Equal: string = 'Equal';
|
||||||
|
public static readonly IntlBackslash: string = 'IntlBackslash';
|
||||||
|
public static readonly IntlRo: string = 'IntlRo';
|
||||||
|
public static readonly IntlYen: string = 'IntlYen';
|
||||||
|
public static readonly KeyA: string = 'KeyA';
|
||||||
|
public static readonly KeyB: string = 'KeyB';
|
||||||
|
public static readonly KeyC: string = 'KeyC';
|
||||||
|
public static readonly KeyD: string = 'KeyD';
|
||||||
|
public static readonly KeyE: string = 'KeyE';
|
||||||
|
public static readonly KeyF: string = 'KeyF';
|
||||||
|
public static readonly KeyG: string = 'KeyG';
|
||||||
|
public static readonly KeyH: string = 'KeyH';
|
||||||
|
public static readonly KeyI: string = 'KeyI';
|
||||||
|
public static readonly KeyJ: string = 'KeyJ';
|
||||||
|
public static readonly KeyK: string = 'KeyK';
|
||||||
|
public static readonly KeyL: string = 'KeyL';
|
||||||
|
public static readonly KeyM: string = 'KeyM';
|
||||||
|
public static readonly KeyN: string = 'KeyN';
|
||||||
|
public static readonly KeyO: string = 'KeyO';
|
||||||
|
public static readonly KeyP: string = 'KeyP';
|
||||||
|
public static readonly KeyQ: string = 'KeyQ';
|
||||||
|
public static readonly KeyR: string = 'KeyR';
|
||||||
|
public static readonly KeyS: string = 'KeyS';
|
||||||
|
public static readonly KeyT: string = 'KeyT';
|
||||||
|
public static readonly KeyU: string = 'KeyU';
|
||||||
|
public static readonly KeyV: string = 'KeyV';
|
||||||
|
public static readonly KeyW: string = 'KeyW';
|
||||||
|
public static readonly KeyX: string = 'KeyX';
|
||||||
|
public static readonly KeyY: string = 'KeyY';
|
||||||
|
public static readonly KeyZ: string = 'KeyZ';
|
||||||
|
public static readonly Minus: string = 'Minus';
|
||||||
|
public static readonly Period: string = 'Period';
|
||||||
|
public static readonly Quote: string = 'Quote';
|
||||||
|
public static readonly Semicolon: string = 'Semicolon';
|
||||||
|
public static readonly Slash: string = 'Slash';
|
||||||
|
|
||||||
|
// 3.1.1.2. Functional Keys
|
||||||
|
public static readonly AltLeft: string = 'AltLeft';
|
||||||
|
public static readonly AltRight: string = 'AltRight';
|
||||||
|
public static readonly Backspace: string = 'Backspace';
|
||||||
|
public static readonly CapsLock: string = 'CapsLock';
|
||||||
|
public static readonly ContextMenu: string = 'ContextMenu';
|
||||||
|
public static readonly ControlLeft: string = 'ControlLeft';
|
||||||
|
public static readonly ControlRight: string = 'ControlRight';
|
||||||
|
public static readonly Enter: string = 'Enter';
|
||||||
|
public static readonly MetaLeft: string = 'MetaLeft';
|
||||||
|
public static readonly MetaRight: string = 'MetaRight';
|
||||||
|
public static readonly ShiftLeft: string = 'ShiftLeft';
|
||||||
|
public static readonly ShiftRight: string = 'ShiftRight';
|
||||||
|
public static readonly Space: string = 'Space';
|
||||||
|
public static readonly Tab: string = 'Tab';
|
||||||
|
public static readonly Convert: string = 'Convert';
|
||||||
|
public static readonly KanaMode: string = 'KanaMode';
|
||||||
|
public static readonly Lang1: string = 'Lang1';
|
||||||
|
public static readonly Lang2: string = 'Lang2';
|
||||||
|
public static readonly Lang3: string = 'Lang3';
|
||||||
|
public static readonly Lang4: string = 'Lang4';
|
||||||
|
public static readonly Lang5: string = 'Lang5';
|
||||||
|
public static readonly NonConvert: string = 'NonConvert';
|
||||||
|
|
||||||
|
// 3.1.2. Control Pad Section
|
||||||
|
public static readonly Delete: string = 'Delete';
|
||||||
|
public static readonly End: string = 'End';
|
||||||
|
public static readonly Help: string = 'Help';
|
||||||
|
public static readonly Home: string = 'Home';
|
||||||
|
public static readonly Insert: string = 'Insert';
|
||||||
|
public static readonly PageDown: string = 'PageDown';
|
||||||
|
public static readonly PageUp: string = 'PageUp';
|
||||||
|
|
||||||
|
// 3.1.3. Arrow Pad Section
|
||||||
|
public static readonly ArrowDown: string = 'ArrowDown';
|
||||||
|
public static readonly ArrowLeft: string = 'ArrowLeft';
|
||||||
|
public static readonly ArrowRight: string = 'ArrowRight';
|
||||||
|
public static readonly ArrowUp: string = 'ArrowUp';
|
||||||
|
|
||||||
|
// 3.1.4. Numpad Section
|
||||||
|
public static readonly NumLock: string = 'NumLock';
|
||||||
|
public static readonly Numpad0: string = 'Numpad0';
|
||||||
|
public static readonly Numpad1: string = 'Numpad1';
|
||||||
|
public static readonly Numpad2: string = 'Numpad2';
|
||||||
|
public static readonly Numpad3: string = 'Numpad3';
|
||||||
|
public static readonly Numpad4: string = 'Numpad4';
|
||||||
|
public static readonly Numpad5: string = 'Numpad5';
|
||||||
|
public static readonly Numpad6: string = 'Numpad6';
|
||||||
|
public static readonly Numpad7: string = 'Numpad7';
|
||||||
|
public static readonly Numpad8: string = 'Numpad8';
|
||||||
|
public static readonly Numpad9: string = 'Numpad9';
|
||||||
|
public static readonly NumpadAdd: string = 'NumpadAdd';
|
||||||
|
public static readonly NumpadBackspace: string = 'NumpadBackspace';
|
||||||
|
public static readonly NumpadClear: string = 'NumpadClear';
|
||||||
|
public static readonly NumpadClearEntry: string = 'NumpadClearEntry';
|
||||||
|
public static readonly NumpadComma: string = 'NumpadComma';
|
||||||
|
public static readonly NumpadDecimal: string = 'NumpadDecimal';
|
||||||
|
public static readonly NumpadDivide: string = 'NumpadDivide';
|
||||||
|
public static readonly NumpadEnter: string = 'NumpadEnter';
|
||||||
|
public static readonly NumpadEqual: string = 'NumpadEqual';
|
||||||
|
public static readonly NumpadHash: string = 'NumpadHash';
|
||||||
|
public static readonly NumpadMemoryAdd: string = 'NumpadMemoryAdd';
|
||||||
|
public static readonly NumpadMemoryClear: string = 'NumpadMemoryClear';
|
||||||
|
public static readonly NumpadMemoryRecall: string = 'NumpadMemoryRecall';
|
||||||
|
public static readonly NumpadMemoryStore: string = 'NumpadMemoryStore';
|
||||||
|
public static readonly NumpadMemorySubtract: string = 'NumpadMemorySubtract';
|
||||||
|
public static readonly NumpadMultiply: string = 'NumpadMultiply';
|
||||||
|
public static readonly NumpadParenLeft: string = 'NumpadParenLeft';
|
||||||
|
public static readonly NumpadParenRight: string = 'NumpadParenRight';
|
||||||
|
public static readonly NumpadStar: string = 'NumpadStar';
|
||||||
|
public static readonly NumpadSubtract: string = 'NumpadSubtract';
|
||||||
|
|
||||||
|
// 3.1.5. Function Section
|
||||||
|
public static readonly Escape: string = 'Escape';
|
||||||
|
public static readonly F1: string = 'F1';
|
||||||
|
public static readonly F2: string = 'F2';
|
||||||
|
public static readonly F3: string = 'F3';
|
||||||
|
public static readonly F4: string = 'F4';
|
||||||
|
public static readonly F5: string = 'F5';
|
||||||
|
public static readonly F6: string = 'F6';
|
||||||
|
public static readonly F7: string = 'F7';
|
||||||
|
public static readonly F8: string = 'F8';
|
||||||
|
public static readonly F9: string = 'F9';
|
||||||
|
public static readonly F10: string = 'F10';
|
||||||
|
public static readonly F11: string = 'F11';
|
||||||
|
public static readonly F12: string = 'F12';
|
||||||
|
public static readonly Fn: string = 'Fn';
|
||||||
|
public static readonly FnLock: string = 'FnLock';
|
||||||
|
public static readonly PrintScreen: string = 'PrintScreen';
|
||||||
|
public static readonly ScrollLock: string = 'ScrollLock';
|
||||||
|
public static readonly Pause: string = 'Pause';
|
||||||
|
|
||||||
|
// 3.1.6. Media Keys
|
||||||
|
public static readonly BrowserBack: string = 'BrowserBack';
|
||||||
|
public static readonly BrowserFavorites: string = 'BrowserFavorites';
|
||||||
|
public static readonly BrowserForward: string = 'BrowserForward';
|
||||||
|
public static readonly BrowserHome: string = 'BrowserHome';
|
||||||
|
public static readonly BrowserRefresh: string = 'BrowserRefresh';
|
||||||
|
public static readonly BrowserSearch: string = 'BrowserSearch';
|
||||||
|
public static readonly BrowserStop: string = 'BrowserStop';
|
||||||
|
public static readonly Eject: string = 'Eject';
|
||||||
|
public static readonly LaunchApp1: string = 'LaunchApp1';
|
||||||
|
public static readonly LaunchApp2: string = 'LaunchApp2';
|
||||||
|
public static readonly LaunchMail: string = 'LaunchMail';
|
||||||
|
public static readonly MediaPlayPause: string = 'MediaPlayPause';
|
||||||
|
public static readonly MediaSelect: string = 'MediaSelect';
|
||||||
|
public static readonly MediaStop: string = 'MediaStop';
|
||||||
|
public static readonly MediaTrackNext: string = 'MediaTrackNext';
|
||||||
|
public static readonly MediaTrackPrevious: string = 'MediaTrackPrevious';
|
||||||
|
public static readonly Power: string = 'Power';
|
||||||
|
public static readonly Sleep: string = 'Sleep';
|
||||||
|
public static readonly AudioVolumeDown: string = 'AudioVolumeDown';
|
||||||
|
public static readonly AudioVolumeMute: string = 'AudioVolumeMute';
|
||||||
|
public static readonly AudioVolumeUp: string = 'AudioVolumeUp';
|
||||||
|
public static readonly WakeUp: string = 'WakeUp';
|
||||||
|
|
||||||
|
// 3.1.7. Legacy, Non-Standard and Special Keys
|
||||||
|
public static readonly Hyper: string = 'Hyper';
|
||||||
|
public static readonly Super: string = 'Super';
|
||||||
|
public static readonly Turbo: string = 'Turbo';
|
||||||
|
public static readonly Abort: string = 'Abort';
|
||||||
|
public static readonly Resume: string = 'Resume';
|
||||||
|
public static readonly Suspend: string = 'Suspend';
|
||||||
|
public static readonly Again: string = 'Again';
|
||||||
|
public static readonly Copy: string = 'Copy';
|
||||||
|
public static readonly Cut: string = 'Cut';
|
||||||
|
public static readonly Find: string = 'Find';
|
||||||
|
public static readonly Open: string = 'Open';
|
||||||
|
public static readonly Paste: string = 'Paste';
|
||||||
|
public static readonly Props: string = 'Props';
|
||||||
|
public static readonly Select: string = 'Select';
|
||||||
|
public static readonly Undo: string = 'Undo';
|
||||||
|
public static readonly Hiragana: string = 'Hiragana';
|
||||||
|
public static readonly Katakana: string = 'Katakana';
|
||||||
|
public static readonly Unidentified: string = 'Unidentified';
|
||||||
|
}
|
||||||
219
src/app/Util.ts
Normal file
219
src/app/Util.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
export default class Util {
|
||||||
|
private static SUFFIX: Record<number, string> = {
|
||||||
|
0: 'B',
|
||||||
|
1: 'KiB',
|
||||||
|
2: 'MiB',
|
||||||
|
3: 'GiB',
|
||||||
|
4: 'TiB',
|
||||||
|
};
|
||||||
|
private static supportsPassiveValue: boolean | undefined;
|
||||||
|
|
||||||
|
public static filterTrailingZeroes(bytes: Uint8Array): Uint8Array {
|
||||||
|
let b = 0;
|
||||||
|
return bytes
|
||||||
|
.reverse()
|
||||||
|
.filter((i) => b || (b = i))
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static prettyBytes(value: number): string {
|
||||||
|
let suffix = 0;
|
||||||
|
while (value >= 512) {
|
||||||
|
suffix++;
|
||||||
|
value /= 1024;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(suffix ? 1 : 0)}${Util.SUFFIX[suffix]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static escapeUdid(udid: string): string {
|
||||||
|
return 'udid_' + udid.replace(/[. :]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parse(params: URLSearchParams, name: string, required?: boolean): string | null {
|
||||||
|
const value = params.get(name);
|
||||||
|
if (required && value === null) {
|
||||||
|
throw TypeError(`Missing required parameter "${name}"`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseString(params: URLSearchParams, name: string, required?: boolean): string {
|
||||||
|
const value = params.get(name);
|
||||||
|
if (required && value === null) {
|
||||||
|
throw TypeError(`Missing required parameter "${name}"`);
|
||||||
|
}
|
||||||
|
return value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseBoolean(params: URLSearchParams, name: string, required?: boolean): boolean {
|
||||||
|
const value = this.parse(params, name, required);
|
||||||
|
return value === '1' || (!!value && value.toString() === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseInt(params: URLSearchParams, name: string, required?: boolean): number {
|
||||||
|
const value = this.parse(params, name, required);
|
||||||
|
if (value === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const int = parseInt(value, 10);
|
||||||
|
if (isNaN(int)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return int;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseBooleanEnv(input: string | string[] | boolean | undefined | null): boolean | undefined {
|
||||||
|
if (typeof input === 'boolean') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
if (typeof input === 'undefined' || input === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
input = input[input.length - 1];
|
||||||
|
}
|
||||||
|
return input === '1' || input.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseStringEnv(input: string | string[] | undefined | null): string | undefined {
|
||||||
|
if (typeof input === 'undefined' || input === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
input = input[input.length - 1];
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
public static parseIntEnv(input: string | string[] | number | undefined | null): number | undefined {
|
||||||
|
if (typeof input === 'number') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
if (typeof input === 'undefined' || input === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
input = input[input.length - 1];
|
||||||
|
}
|
||||||
|
const int = parseInt(input, 10);
|
||||||
|
if (isNaN(int)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return int;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// https://github.com/google/closure-library/blob/51e5a5ac373aefa354a991816ec418d730e29a7e/closure/goog/crypt/crypt.js#L117
|
||||||
|
/*
|
||||||
|
Copyright 2008 The Closure Library Authors. All Rights Reserved.
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS-IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* Converts a JS string to a UTF-8 "byte" array.
|
||||||
|
* @param {string} str 16-bit unicode string.
|
||||||
|
* @return {!Array<number>} UTF-8 byte array.
|
||||||
|
*/
|
||||||
|
static stringToUtf8ByteArray = function(str: string) {
|
||||||
|
// TODO(user): Use native implementations if/when available
|
||||||
|
var out = [], p = 0;
|
||||||
|
for (var i = 0; i < str.length; i++) {
|
||||||
|
var c = str.charCodeAt(i);
|
||||||
|
if (c < 128) {
|
||||||
|
out[p++] = c;
|
||||||
|
} else if (c < 2048) {
|
||||||
|
out[p++] = (c >> 6) | 192;
|
||||||
|
out[p++] = (c & 63) | 128;
|
||||||
|
} else if (
|
||||||
|
((c & 0xFC00) == 0xD800) && (i + 1) < str.length &&
|
||||||
|
((str.charCodeAt(i + 1) & 0xFC00) == 0xDC00)) {
|
||||||
|
// Surrogate Pair
|
||||||
|
c = 0x10000 + ((c & 0x03FF) << 10) + (str.charCodeAt(++i) & 0x03FF);
|
||||||
|
out[p++] = (c >> 18) | 240;
|
||||||
|
out[p++] = ((c >> 12) & 63) | 128;
|
||||||
|
out[p++] = ((c >> 6) & 63) | 128;
|
||||||
|
out[p++] = (c & 63) | 128;
|
||||||
|
} else {
|
||||||
|
out[p++] = (c >> 12) | 224;
|
||||||
|
out[p++] = ((c >> 6) & 63) | 128;
|
||||||
|
out[p++] = (c & 63) | 128;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Uint8Array.from(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a UTF-8 byte array to JavaScript's 16-bit Unicode.
|
||||||
|
* @param {Uint8Array|Array<number>} bytes UTF-8 byte array.
|
||||||
|
* @return {string} 16-bit Unicode string.
|
||||||
|
*/
|
||||||
|
static utf8ByteArrayToString(bytes: Uint8Array): string {
|
||||||
|
// TODO(user): Use native implementations if/when available
|
||||||
|
var out = [], pos = 0, c = 0;
|
||||||
|
while (pos < bytes.length) {
|
||||||
|
var c1 = bytes[pos++];
|
||||||
|
if (c1 < 128) {
|
||||||
|
out[c++] = String.fromCharCode(c1);
|
||||||
|
} else if (c1 > 191 && c1 < 224) {
|
||||||
|
var c2 = bytes[pos++];
|
||||||
|
out[c++] = String.fromCharCode((c1 & 31) << 6 | c2 & 63);
|
||||||
|
} else if (c1 > 239 && c1 < 365) {
|
||||||
|
// Surrogate Pair
|
||||||
|
var c2 = bytes[pos++];
|
||||||
|
var c3 = bytes[pos++];
|
||||||
|
var c4 = bytes[pos++];
|
||||||
|
var u = ((c1 & 7) << 18 | (c2 & 63) << 12 | (c3 & 63) << 6 | c4 & 63) -
|
||||||
|
0x10000;
|
||||||
|
out[c++] = String.fromCharCode(0xD800 + (u >> 10));
|
||||||
|
out[c++] = String.fromCharCode(0xDC00 + (u & 1023));
|
||||||
|
} else {
|
||||||
|
var c2 = bytes[pos++];
|
||||||
|
var c3 = bytes[pos++];
|
||||||
|
out[c++] =
|
||||||
|
String.fromCharCode((c1 & 15) << 12 | (c2 & 63) << 6 | c3 & 63);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
};
|
||||||
|
/* tslint:enable */
|
||||||
|
|
||||||
|
// https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
|
||||||
|
static supportsPassive(): boolean {
|
||||||
|
if (typeof Util.supportsPassiveValue === 'boolean') {
|
||||||
|
return Util.supportsPassiveValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test via a getter in the options object to see if the passive property is accessed
|
||||||
|
let supportsPassive = false;
|
||||||
|
try {
|
||||||
|
const opts = Object.defineProperty({}, 'passive', {
|
||||||
|
get: function() {
|
||||||
|
supportsPassive = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
window.addEventListener('testPassive', null, opts);
|
||||||
|
// @ts-ignore
|
||||||
|
window.removeEventListener('testPassive', null, opts);
|
||||||
|
} catch (error: any) {}
|
||||||
|
|
||||||
|
return Util.supportsPassiveValue = supportsPassive;
|
||||||
|
|
||||||
|
// Use our detect's results. passive applied if supported, capture will be false either way.
|
||||||
|
// elem.addEventListener('touchstart', fn, supportsPassive ? { passive: true } : false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static setImmediate(fn: () => any): void {
|
||||||
|
Promise.resolve().then(fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/app/VideoSettings.ts
Normal file
228
src/app/VideoSettings.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import Rect from './Rect';
|
||||||
|
import Size from './Size';
|
||||||
|
import Util from './Util';
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
crop?: Rect | null;
|
||||||
|
bitrate: number;
|
||||||
|
bounds?: Size | null;
|
||||||
|
maxFps: number;
|
||||||
|
iFrameInterval: number;
|
||||||
|
sendFrameMeta?: boolean;
|
||||||
|
lockedVideoOrientation?: number;
|
||||||
|
displayId?: number;
|
||||||
|
codecOptions?: string;
|
||||||
|
encoderName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class VideoSettings {
|
||||||
|
public static readonly BASE_BUFFER_LENGTH: number = 35;
|
||||||
|
public readonly crop?: Rect | null = null;
|
||||||
|
public readonly bitrate: number = 0;
|
||||||
|
public readonly bounds?: Size | null = null;
|
||||||
|
public readonly maxFps: number = 0;
|
||||||
|
public readonly iFrameInterval: number = 0;
|
||||||
|
public readonly sendFrameMeta: boolean = false;
|
||||||
|
public readonly lockedVideoOrientation: number = -1;
|
||||||
|
public readonly displayId: number = 0;
|
||||||
|
public readonly codecOptions?: string;
|
||||||
|
public readonly encoderName?: string;
|
||||||
|
|
||||||
|
constructor(data?: Settings, public readonly bytesLength: number = VideoSettings.BASE_BUFFER_LENGTH) {
|
||||||
|
if (data) {
|
||||||
|
this.crop = data.crop;
|
||||||
|
this.bitrate = data.bitrate;
|
||||||
|
this.bounds = data.bounds;
|
||||||
|
this.maxFps = data.maxFps;
|
||||||
|
this.iFrameInterval = data.iFrameInterval;
|
||||||
|
this.sendFrameMeta = data.sendFrameMeta || false;
|
||||||
|
this.lockedVideoOrientation = data.lockedVideoOrientation || -1;
|
||||||
|
if (typeof data.displayId === 'number' && !isNaN(data.displayId) && data.displayId >= 0) {
|
||||||
|
this.displayId = data.displayId;
|
||||||
|
}
|
||||||
|
if (data.codecOptions) {
|
||||||
|
this.codecOptions = data.codecOptions.trim();
|
||||||
|
}
|
||||||
|
if (data.encoderName) {
|
||||||
|
this.encoderName = data.encoderName.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fromBuffer(buffer: Buffer): VideoSettings {
|
||||||
|
let offset = 0;
|
||||||
|
const bitrate = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
const maxFps = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
const iFrameInterval = buffer.readInt8(offset);
|
||||||
|
offset += 1;
|
||||||
|
const width = buffer.readInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const height = buffer.readInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const left = buffer.readInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const top = buffer.readInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const right = buffer.readInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const bottom = buffer.readInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const sendFrameMeta = !!buffer.readInt8(offset);
|
||||||
|
offset += 1;
|
||||||
|
const lockedVideoOrientation = buffer.readInt8(offset);
|
||||||
|
offset += 1;
|
||||||
|
const displayId = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
let bounds: Size | null = null;
|
||||||
|
let crop: Rect | null = null;
|
||||||
|
if (width !== 0 && height !== 0) {
|
||||||
|
bounds = new Size(width, height);
|
||||||
|
}
|
||||||
|
if (left || top || right || bottom) {
|
||||||
|
crop = new Rect(left, top, right, bottom);
|
||||||
|
}
|
||||||
|
let codecOptions;
|
||||||
|
let encoderName;
|
||||||
|
const codecOptionsLength = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (codecOptionsLength) {
|
||||||
|
const codecOptionsBytes = buffer.slice(offset, offset + codecOptionsLength);
|
||||||
|
offset += codecOptionsLength;
|
||||||
|
codecOptions = Util.utf8ByteArrayToString(codecOptionsBytes);
|
||||||
|
}
|
||||||
|
const encoderNameLength = buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (encoderNameLength) {
|
||||||
|
const encoderNameBytes = buffer.slice(offset, offset + encoderNameLength);
|
||||||
|
offset += encoderNameLength;
|
||||||
|
encoderName = Util.utf8ByteArrayToString(encoderNameBytes);
|
||||||
|
}
|
||||||
|
return new VideoSettings(
|
||||||
|
{
|
||||||
|
crop,
|
||||||
|
bitrate,
|
||||||
|
bounds,
|
||||||
|
maxFps,
|
||||||
|
iFrameInterval,
|
||||||
|
lockedVideoOrientation,
|
||||||
|
displayId,
|
||||||
|
sendFrameMeta,
|
||||||
|
codecOptions,
|
||||||
|
encoderName,
|
||||||
|
},
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static copy(a: VideoSettings): VideoSettings {
|
||||||
|
return new VideoSettings(
|
||||||
|
{
|
||||||
|
bitrate: a.bitrate,
|
||||||
|
crop: Rect.copy(a.crop),
|
||||||
|
bounds: Size.copy(a.bounds),
|
||||||
|
maxFps: a.maxFps,
|
||||||
|
iFrameInterval: a.iFrameInterval,
|
||||||
|
lockedVideoOrientation: a.lockedVideoOrientation,
|
||||||
|
displayId: a.displayId,
|
||||||
|
sendFrameMeta: a.sendFrameMeta,
|
||||||
|
codecOptions: a.codecOptions,
|
||||||
|
encoderName: a.encoderName,
|
||||||
|
},
|
||||||
|
a.bytesLength,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals(o?: VideoSettings | null): boolean {
|
||||||
|
if (!o) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.encoderName === o.encoderName &&
|
||||||
|
this.codecOptions === o.codecOptions &&
|
||||||
|
Rect.equals(this.crop, o.crop) &&
|
||||||
|
this.lockedVideoOrientation === o.lockedVideoOrientation &&
|
||||||
|
this.displayId === o.displayId &&
|
||||||
|
Size.equals(this.bounds, o.bounds) &&
|
||||||
|
this.bitrate === o.bitrate &&
|
||||||
|
this.maxFps === o.maxFps &&
|
||||||
|
this.iFrameInterval === o.iFrameInterval
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toBuffer(): Buffer {
|
||||||
|
let additionalLength = 0;
|
||||||
|
let codecOptionsBytes;
|
||||||
|
let encoderNameBytes;
|
||||||
|
if (this.codecOptions) {
|
||||||
|
codecOptionsBytes = Util.stringToUtf8ByteArray(this.codecOptions);
|
||||||
|
additionalLength += codecOptionsBytes.length;
|
||||||
|
}
|
||||||
|
if (this.encoderName) {
|
||||||
|
encoderNameBytes = Util.stringToUtf8ByteArray(this.encoderName);
|
||||||
|
additionalLength += encoderNameBytes.length;
|
||||||
|
}
|
||||||
|
const buffer = Buffer.alloc(VideoSettings.BASE_BUFFER_LENGTH + additionalLength);
|
||||||
|
const { width = 0, height = 0 } = this.bounds || {};
|
||||||
|
const { left = 0, top = 0, right = 0, bottom = 0 } = this.crop || {};
|
||||||
|
let offset = 0;
|
||||||
|
offset = buffer.writeInt32BE(this.bitrate, offset);
|
||||||
|
offset = buffer.writeInt32BE(this.maxFps, offset);
|
||||||
|
offset = buffer.writeInt8(this.iFrameInterval, offset);
|
||||||
|
offset = buffer.writeInt16BE(width, offset);
|
||||||
|
offset = buffer.writeInt16BE(height, offset);
|
||||||
|
offset = buffer.writeInt16BE(left, offset);
|
||||||
|
offset = buffer.writeInt16BE(top, offset);
|
||||||
|
offset = buffer.writeInt16BE(right, offset);
|
||||||
|
offset = buffer.writeInt16BE(bottom, offset);
|
||||||
|
offset = buffer.writeInt8(this.sendFrameMeta ? 1 : 0, offset);
|
||||||
|
offset = buffer.writeInt8(this.lockedVideoOrientation, offset);
|
||||||
|
offset = buffer.writeInt32BE(this.displayId, offset);
|
||||||
|
if (codecOptionsBytes) {
|
||||||
|
offset = buffer.writeInt32BE(codecOptionsBytes.length, offset);
|
||||||
|
buffer.fill(codecOptionsBytes, offset);
|
||||||
|
offset += codecOptionsBytes.length;
|
||||||
|
} else {
|
||||||
|
offset = buffer.writeInt32BE(0, offset);
|
||||||
|
}
|
||||||
|
if (encoderNameBytes) {
|
||||||
|
offset = buffer.writeInt32BE(encoderNameBytes.length, offset);
|
||||||
|
buffer.fill(encoderNameBytes, offset);
|
||||||
|
offset += encoderNameBytes.length;
|
||||||
|
} else {
|
||||||
|
buffer.writeInt32BE(0, offset);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
// prettier-ignore
|
||||||
|
return `VideoSettings{bitrate=${
|
||||||
|
this.bitrate}, maxFps=${
|
||||||
|
this.maxFps}, iFrameInterval=${
|
||||||
|
this.iFrameInterval}, bounds=${
|
||||||
|
this.bounds}, crop=${
|
||||||
|
this.crop}, metaFrame=${
|
||||||
|
this.sendFrameMeta}, lockedVideoOrientation=${
|
||||||
|
this.lockedVideoOrientation}, displayId=${
|
||||||
|
this.displayId}, codecOptions=${
|
||||||
|
this.codecOptions}, encoderName=${
|
||||||
|
this.encoderName}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): Settings {
|
||||||
|
return {
|
||||||
|
bitrate: this.bitrate,
|
||||||
|
maxFps: this.maxFps,
|
||||||
|
iFrameInterval: this.iFrameInterval,
|
||||||
|
bounds: this.bounds,
|
||||||
|
crop: this.crop,
|
||||||
|
sendFrameMeta: this.sendFrameMeta,
|
||||||
|
lockedVideoOrientation: this.lockedVideoOrientation,
|
||||||
|
displayId: this.displayId,
|
||||||
|
codecOptions: this.codecOptions,
|
||||||
|
encoderName: this.encoderName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/app/applDevice/client/DeviceTracker.ts
Normal file
80
src/app/applDevice/client/DeviceTracker.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import ApplDeviceDescriptor from '../../../types/ApplDeviceDescriptor';
|
||||||
|
import Util from '../../Util';
|
||||||
|
import { html } from '../../ui/HtmlTag';
|
||||||
|
import { DeviceState } from '../../../common/DeviceState';
|
||||||
|
import { HostItem } from '../../../types/Configuration';
|
||||||
|
import { ChannelCode } from '../../../common/ChannelCode';
|
||||||
|
import { Tool } from '../../client/Tool';
|
||||||
|
|
||||||
|
export class DeviceTracker extends BaseDeviceTracker<ApplDeviceDescriptor, never> {
|
||||||
|
public static ACTION = ACTION.APPL_DEVICE_LIST;
|
||||||
|
protected static tools: Set<Tool> = new Set();
|
||||||
|
private static instancesByUrl: Map<string, DeviceTracker> = new Map();
|
||||||
|
|
||||||
|
public static start(hostItem: HostItem): DeviceTracker {
|
||||||
|
const url = this.buildUrlForTracker(hostItem).toString();
|
||||||
|
let instance = this.instancesByUrl.get(url);
|
||||||
|
if (!instance) {
|
||||||
|
instance = new DeviceTracker(hostItem, url);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(hostItem: HostItem): DeviceTracker {
|
||||||
|
return this.start(hostItem);
|
||||||
|
}
|
||||||
|
protected tableId = 'appl_devices_list';
|
||||||
|
constructor(params: HostItem, directUrl: string) {
|
||||||
|
super({ ...params, action: DeviceTracker.ACTION }, directUrl);
|
||||||
|
DeviceTracker.instancesByUrl.set(directUrl, this);
|
||||||
|
this.buildDeviceTable();
|
||||||
|
this.openNewConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketOpen(): void {
|
||||||
|
// do nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildDeviceRow(tbody: Element, device: ApplDeviceDescriptor): void {
|
||||||
|
const blockClass = 'desc-block';
|
||||||
|
const fullName = `${this.id}_${Util.escapeUdid(device.udid)}`;
|
||||||
|
const isActive = device.state === DeviceState.CONNECTED;
|
||||||
|
const servicesId = `device_services_${fullName}`;
|
||||||
|
const row = html`<div class="device ${isActive ? 'active' : 'not-active'}">
|
||||||
|
<div class="device-header">
|
||||||
|
<div class="device-name">"${device.name}"</div>
|
||||||
|
<div class="device-model">${device.model}</div>
|
||||||
|
<div class="device-serial">${device.udid}</div>
|
||||||
|
<div class="device-version">
|
||||||
|
<div class="release-version">${device.version}</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-state" title="State: ${device.state}"></div>
|
||||||
|
</div>
|
||||||
|
<div id="${servicesId}" class="services"></div>
|
||||||
|
</div>`.content;
|
||||||
|
const services = row.getElementById(servicesId);
|
||||||
|
if (!services) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceTracker.tools.forEach((tool) => {
|
||||||
|
const entry = tool.createEntryForDeviceList(device, blockClass, this.params);
|
||||||
|
if (entry) {
|
||||||
|
if (Array.isArray(entry)) {
|
||||||
|
entry.forEach((item) => {
|
||||||
|
item && services.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
services.appendChild(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelCode(): string {
|
||||||
|
return ChannelCode.ATRC;
|
||||||
|
}
|
||||||
|
}
|
||||||
247
src/app/applDevice/client/StreamClient.ts
Normal file
247
src/app/applDevice/client/StreamClient.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { BaseClient } from '../../client/BaseClient';
|
||||||
|
import { ParamsStream } from '../../../types/ParamsStream';
|
||||||
|
import { SimpleInteractionHandler } from '../../interactionHandler/SimpleInteractionHandler';
|
||||||
|
import { BasePlayer, PlayerClass } from '../../player/BasePlayer';
|
||||||
|
import ScreenInfo from '../../ScreenInfo';
|
||||||
|
import { WdaProxyClient } from './WdaProxyClient';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import { ApplMoreBox } from '../toolbox/ApplMoreBox';
|
||||||
|
import { ApplToolBox } from '../toolbox/ApplToolBox';
|
||||||
|
import Size from '../../Size';
|
||||||
|
import Util from '../../Util';
|
||||||
|
import ApplDeviceDescriptor from '../../../types/ApplDeviceDescriptor';
|
||||||
|
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
|
||||||
|
import { DeviceTracker } from './DeviceTracker';
|
||||||
|
import { WdaStatus } from '../../../common/WdaStatus';
|
||||||
|
import { MessageRunWdaResponse } from '../../../types/MessageRunWdaResponse';
|
||||||
|
|
||||||
|
const WAIT_CLASS = 'wait';
|
||||||
|
const TAG = 'StreamClient';
|
||||||
|
|
||||||
|
export interface StreamClientEvents {
|
||||||
|
'wda:status': WdaStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class StreamClient<T extends ParamsStream> extends BaseClient<T, StreamClientEvents> {
|
||||||
|
public static ACTION = 'MUST_OVERRIDE';
|
||||||
|
protected static players: Map<string, PlayerClass> = new Map<string, PlayerClass>();
|
||||||
|
|
||||||
|
public static registerPlayer(playerClass: PlayerClass): void {
|
||||||
|
if (playerClass.isSupported()) {
|
||||||
|
this.players.set(playerClass.playerFullName, playerClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getPlayers(): PlayerClass[] {
|
||||||
|
return Array.from(this.players.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getPlayerClass(playerName?: string): PlayerClass | undefined {
|
||||||
|
let playerClass: PlayerClass | undefined;
|
||||||
|
for (const value of this.players.values()) {
|
||||||
|
if (value.playerFullName === playerName || value.playerCodeName === playerName) {
|
||||||
|
playerClass = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return playerClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createPlayer(udid: string, playerName?: string): BasePlayer {
|
||||||
|
if (!playerName) {
|
||||||
|
throw Error('Must provide player name');
|
||||||
|
}
|
||||||
|
const playerClass = this.getPlayerClass(playerName);
|
||||||
|
if (!playerClass) {
|
||||||
|
throw Error(`Unsupported player "${playerName}"`);
|
||||||
|
}
|
||||||
|
return new playerClass(udid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createEntryForDeviceList(
|
||||||
|
descriptor: ApplDeviceDescriptor,
|
||||||
|
blockClass: string,
|
||||||
|
params: ParamsDeviceTracker,
|
||||||
|
): Array<HTMLElement | DocumentFragment | undefined> {
|
||||||
|
const entries: Array<HTMLElement | DocumentFragment> = [];
|
||||||
|
const players = this.getPlayers();
|
||||||
|
players.forEach((playerClass) => {
|
||||||
|
const { playerCodeName, playerFullName } = playerClass;
|
||||||
|
const playerTd = document.createElement('div');
|
||||||
|
playerTd.classList.add(blockClass);
|
||||||
|
playerTd.setAttribute(DeviceTracker.AttributePlayerFullName, encodeURIComponent(playerFullName));
|
||||||
|
playerTd.setAttribute(DeviceTracker.AttributePlayerCodeName, encodeURIComponent(playerCodeName));
|
||||||
|
const q: any = {
|
||||||
|
action: this.ACTION,
|
||||||
|
player: playerCodeName,
|
||||||
|
udid: descriptor.udid,
|
||||||
|
};
|
||||||
|
const link = DeviceTracker.buildLink(q, `Stream (${playerFullName})`, params);
|
||||||
|
playerTd.appendChild(link);
|
||||||
|
entries.push(playerTd);
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static getMaxSize(controlButtons: HTMLElement): Size | undefined {
|
||||||
|
if (!controlButtons) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = document.body;
|
||||||
|
const width = (body.clientWidth - controlButtons.clientWidth) & ~15;
|
||||||
|
const height = body.clientHeight & ~15;
|
||||||
|
return new Size(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForWda?: Promise<void>;
|
||||||
|
protected touchHandler?: SimpleInteractionHandler;
|
||||||
|
protected readonly wdaProxy: WdaProxyClient;
|
||||||
|
protected name: string;
|
||||||
|
protected udid: string;
|
||||||
|
protected deviceName = '';
|
||||||
|
protected videoWrapper: HTMLElement;
|
||||||
|
protected deviceView?: HTMLDivElement;
|
||||||
|
protected moreBox?: HTMLElement;
|
||||||
|
protected player?: BasePlayer;
|
||||||
|
|
||||||
|
protected constructor(params: T) {
|
||||||
|
super(params);
|
||||||
|
this.udid = this.params.udid;
|
||||||
|
this.wdaProxy = new WdaProxyClient({ ...this.params, action: ACTION.PROXY_WDA });
|
||||||
|
this.name = `[${TAG}:${this.udid}]`;
|
||||||
|
this.videoWrapper = document.createElement('div');
|
||||||
|
this.videoWrapper.className = `video`;
|
||||||
|
this.setWdaStatusNotification(WdaStatus.STARTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get action(): string {
|
||||||
|
return StreamClient.ACTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseParameters(params: URLSearchParams): ParamsStream {
|
||||||
|
const typedParams = super.parseParameters(params);
|
||||||
|
const { action } = typedParams;
|
||||||
|
if (action !== this.action) {
|
||||||
|
throw Error('Incorrect action');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...typedParams,
|
||||||
|
action,
|
||||||
|
udid: Util.parseString(params, 'udid', true),
|
||||||
|
player: Util.parseString(params, 'player', true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public createPlayer(udid: string, playerName?: string): BasePlayer {
|
||||||
|
return StreamClient.createPlayer(udid, playerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMaxSize(controlButtons: HTMLElement): Size | undefined {
|
||||||
|
return StreamClient.getMaxSize(controlButtons);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async runWebDriverAgent(): Promise<void> {
|
||||||
|
if (!this.waitForWda) {
|
||||||
|
this.wdaProxy.on('wda-status', this.handleWdaStatus);
|
||||||
|
this.waitForWda = this.wdaProxy.runWebDriverAgent().then(this.handleWdaStatus);
|
||||||
|
}
|
||||||
|
return this.waitForWda;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleWdaStatus = (message: MessageRunWdaResponse): void => {
|
||||||
|
const data = message.data;
|
||||||
|
this.setWdaStatusNotification(data.status);
|
||||||
|
switch (data.status) {
|
||||||
|
case WdaStatus.STARTING:
|
||||||
|
case WdaStatus.STARTED:
|
||||||
|
case WdaStatus.STOPPED:
|
||||||
|
this.emit('wda:status', data.status);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Error(`Unknown WDA status: '${status}'`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected setTouchListeners(player: BasePlayer): void {
|
||||||
|
if (this.touchHandler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.touchHandler = new SimpleInteractionHandler(player, this.wdaProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onInputVideoResize = (screenInfo: ScreenInfo): void => {
|
||||||
|
this.wdaProxy.setScreenInfo(screenInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
public onStop(ev?: string | Event): void {
|
||||||
|
if (ev && ev instanceof Event && ev.type === 'error') {
|
||||||
|
console.error(TAG, ev);
|
||||||
|
}
|
||||||
|
if (this.deviceView) {
|
||||||
|
const parent = this.deviceView.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.removeChild(this.deviceView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.moreBox) {
|
||||||
|
const parent = this.moreBox.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.removeChild(this.moreBox);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.wdaProxy.stop();
|
||||||
|
this.player?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setWdaStatusNotification(status: WdaStatus): void {
|
||||||
|
// TODO: use proper notification instead of `cursor: wait`
|
||||||
|
if (status === WdaStatus.STARTED || status === WdaStatus.STOPPED) {
|
||||||
|
this.videoWrapper.classList.remove(WAIT_CLASS);
|
||||||
|
} else {
|
||||||
|
this.videoWrapper.classList.add(WAIT_CLASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createMoreBox(udid: string, player: BasePlayer): ApplMoreBox {
|
||||||
|
return new ApplMoreBox(udid, player, this.wdaProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected startStream(inputPlayer?: BasePlayer): void {
|
||||||
|
const { udid, player: playerName } = this.params;
|
||||||
|
if (!udid) {
|
||||||
|
throw Error(`Invalid udid value: "${udid}"`);
|
||||||
|
}
|
||||||
|
let player: BasePlayer;
|
||||||
|
if (inputPlayer) {
|
||||||
|
player = inputPlayer;
|
||||||
|
} else {
|
||||||
|
player = this.createPlayer(udid, playerName);
|
||||||
|
}
|
||||||
|
this.setTouchListeners(player);
|
||||||
|
player.pause();
|
||||||
|
|
||||||
|
const deviceView = document.createElement('div');
|
||||||
|
deviceView.className = 'device-view';
|
||||||
|
|
||||||
|
const applMoreBox = this.createMoreBox(udid, player);
|
||||||
|
applMoreBox.setOnStop(this);
|
||||||
|
const moreBox: HTMLElement = applMoreBox.getHolderElement();
|
||||||
|
const applToolBox = ApplToolBox.createToolBox(udid, player, this, this.wdaProxy, moreBox);
|
||||||
|
const controlButtons = applToolBox.getHolderElement();
|
||||||
|
deviceView.appendChild(controlButtons);
|
||||||
|
deviceView.appendChild(this.videoWrapper);
|
||||||
|
deviceView.appendChild(moreBox);
|
||||||
|
player.setParent(this.videoWrapper);
|
||||||
|
player.on('input-video-resize', this.onInputVideoResize);
|
||||||
|
|
||||||
|
document.body.appendChild(deviceView);
|
||||||
|
const bounds = this.getMaxSize(controlButtons);
|
||||||
|
if (bounds) {
|
||||||
|
player.setBounds(bounds);
|
||||||
|
}
|
||||||
|
this.player = player;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDeviceName(): string {
|
||||||
|
return this.deviceName;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/app/applDevice/client/StreamClientMJPEG.ts
Normal file
50
src/app/applDevice/client/StreamClientMJPEG.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { ParamsStream } from '../../../types/ParamsStream';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import { StreamClient } from './StreamClient';
|
||||||
|
import { BasePlayer, PlayerClass } from '../../player/BasePlayer';
|
||||||
|
import { WdaStatus } from '../../../common/WdaStatus';
|
||||||
|
import { ApplMjpegMoreBox } from '../toolbox/ApplMjpegMoreBox';
|
||||||
|
|
||||||
|
const TAG = '[StreamClientMJPEG]';
|
||||||
|
|
||||||
|
export class StreamClientMJPEG extends StreamClient<ParamsStream> {
|
||||||
|
public static ACTION = ACTION.STREAM_MJPEG;
|
||||||
|
protected static players: Map<string, PlayerClass> = new Map<string, PlayerClass>();
|
||||||
|
|
||||||
|
public static start(params: ParamsStream): StreamClientMJPEG {
|
||||||
|
return new StreamClientMJPEG(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(params: ParamsStream) {
|
||||||
|
super(params);
|
||||||
|
this.name = `[${TAG}:${this.udid}]`;
|
||||||
|
this.udid = this.params.udid;
|
||||||
|
this.runWebDriverAgent().then(() => {
|
||||||
|
this.startStream();
|
||||||
|
this.player?.play();
|
||||||
|
});
|
||||||
|
this.on('wda:status', (status) => {
|
||||||
|
if (status === WdaStatus.STOPPED) {
|
||||||
|
this.player?.stop();
|
||||||
|
} else if (status === WdaStatus.STARTED) {
|
||||||
|
this.player?.play();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get action(): string {
|
||||||
|
return StreamClientMJPEG.ACTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createPlayer(udid: string, playerName?: string): BasePlayer {
|
||||||
|
return StreamClientMJPEG.createPlayer(udid, playerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDeviceName(): string {
|
||||||
|
return this.deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createMoreBox(udid: string, player: BasePlayer): ApplMjpegMoreBox {
|
||||||
|
return new ApplMjpegMoreBox(udid, player, this.wdaProxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/app/applDevice/client/StreamClientQVHack.ts
Normal file
72
src/app/applDevice/client/StreamClientQVHack.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { StreamReceiver } from '../../client/StreamReceiver';
|
||||||
|
import { BasePlayer, PlayerClass } from '../../player/BasePlayer';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import { StreamReceiverQVHack } from './StreamReceiverQVHack';
|
||||||
|
import { StreamClient } from './StreamClient';
|
||||||
|
import { ParamsStream } from '../../../types/ParamsStream';
|
||||||
|
|
||||||
|
const TAG = '[StreamClientQVHack]';
|
||||||
|
|
||||||
|
export class StreamClientQVHack extends StreamClient<ParamsStream> {
|
||||||
|
public static ACTION = ACTION.STREAM_WS_QVH;
|
||||||
|
protected static players: Map<string, PlayerClass> = new Map<string, PlayerClass>();
|
||||||
|
|
||||||
|
public static start(params: ParamsStream): StreamClientQVHack {
|
||||||
|
return new StreamClientQVHack(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly streamReceiver: StreamReceiver<ParamsStream>;
|
||||||
|
|
||||||
|
constructor(params: ParamsStream) {
|
||||||
|
super(params);
|
||||||
|
|
||||||
|
this.name = `[${TAG}:${this.udid}]`;
|
||||||
|
this.udid = this.params.udid;
|
||||||
|
let udid = this.udid;
|
||||||
|
// Workaround for qvh v0.5-beta
|
||||||
|
if (udid.indexOf('-') !== -1) {
|
||||||
|
udid = udid.replace('-', '');
|
||||||
|
udid = udid + '\0'.repeat(16);
|
||||||
|
}
|
||||||
|
this.streamReceiver = new StreamReceiverQVHack({ ...this.params, udid });
|
||||||
|
this.startStream();
|
||||||
|
this.setTitle(`${this.udid} stream`);
|
||||||
|
this.setBodyClass('stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get action(): string {
|
||||||
|
return StreamClientQVHack.ACTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createPlayer(udid: string, playerName?: string): BasePlayer {
|
||||||
|
return StreamClientQVHack.createPlayer(udid, playerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onViewVideoResize = (): void => {
|
||||||
|
this.runWebDriverAgent();
|
||||||
|
};
|
||||||
|
|
||||||
|
public onStop(ev?: string | Event): void {
|
||||||
|
super.onStop(ev);
|
||||||
|
this.streamReceiver.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected startStream(inputPlayer?: BasePlayer): void {
|
||||||
|
|
||||||
|
console.log("标识", inputPlayer);
|
||||||
|
super.startStream(inputPlayer);
|
||||||
|
const player = this.player;
|
||||||
|
if (player) {
|
||||||
|
player.on('video-view-resize', this.onViewVideoResize);
|
||||||
|
this.streamReceiver.on('video', (data) => {
|
||||||
|
const STATE = BasePlayer.STATE;
|
||||||
|
if (player.getState() === STATE.PAUSED) {
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
if (player.getState() === STATE.PLAYING) {
|
||||||
|
player.pushFrame(new Uint8Array(data));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/applDevice/client/StreamReceiverQVHack.ts
Normal file
34
src/app/applDevice/client/StreamReceiverQVHack.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { StreamReceiver } from '../../client/StreamReceiver';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import Util from '../../Util';
|
||||||
|
import { ParamsStream } from '../../../types/ParamsStream';
|
||||||
|
import { ChannelCode } from '../../../common/ChannelCode';
|
||||||
|
|
||||||
|
export class StreamReceiverQVHack extends StreamReceiver<ParamsStream> {
|
||||||
|
public static parseParameters(params: URLSearchParams): ParamsStream {
|
||||||
|
const typedParams = super.parseParameters(params);
|
||||||
|
const { action } = typedParams;
|
||||||
|
if (action !== ACTION.STREAM_WS_QVH) {
|
||||||
|
throw Error('Incorrect action');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...typedParams,
|
||||||
|
action,
|
||||||
|
player: Util.parseString(params, 'player', true),
|
||||||
|
udid: Util.parseString(params, 'udid', true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected supportMultiplexing(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelInitData(): Buffer {
|
||||||
|
const udid = Util.stringToUtf8ByteArray(this.params.udid);
|
||||||
|
const buffer = Buffer.alloc(4 + 4 + udid.byteLength);
|
||||||
|
buffer.write(ChannelCode.QVHS, 'ascii');
|
||||||
|
buffer.writeUInt32LE(udid.length, 4);
|
||||||
|
buffer.set(udid, 8);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
309
src/app/applDevice/client/WdaProxyClient.ts
Normal file
309
src/app/applDevice/client/WdaProxyClient.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { ManagerClient } from '../../client/ManagerClient';
|
||||||
|
import { MessageRunWdaResponse } from '../../../types/MessageRunWdaResponse';
|
||||||
|
import { Message } from '../../../types/Message';
|
||||||
|
import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
|
||||||
|
import { ParamsWdaProxy } from '../../../types/ParamsWdaProxy';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import Util from '../../Util';
|
||||||
|
import { ChannelCode } from '../../../common/ChannelCode';
|
||||||
|
import { WDAMethod } from '../../../common/WDAMethod';
|
||||||
|
import ScreenInfo from '../../ScreenInfo';
|
||||||
|
import Position from '../../Position';
|
||||||
|
import Point from '../../Point';
|
||||||
|
import { TouchHandlerListener } from '../../interactionHandler/SimpleInteractionHandler';
|
||||||
|
|
||||||
|
export type WdaProxyClientEvents = {
|
||||||
|
'wda-status': MessageRunWdaResponse;
|
||||||
|
connected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MjpegServerOptions = {
|
||||||
|
// The maximum count of screenshots per second taken by the MJPEG screenshots broadcaster.
|
||||||
|
// Must be in range 1..60. 10 by default
|
||||||
|
mjpegServerFramerate?: number;
|
||||||
|
|
||||||
|
// The percentage value used to apply downscaling on the screenshots generated by the MJPEG screenshots
|
||||||
|
// broadcaster. Must be in range 1..100. 100 is by default, which means that screenshots are not downscaled.
|
||||||
|
mjpegScalingFactor?: number;
|
||||||
|
|
||||||
|
// The percentage value used to apply lossy JPEG compression on the screenshots generated by the MJPEG
|
||||||
|
// screenshots broadcaster. Must be in range 1..100. 25 is by default, which means that screenshots are
|
||||||
|
// compressed to the quarter of their original quality.
|
||||||
|
mjpegServerScreenshotQuality?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DefaultMjpegServerOption: MjpegServerOptions = {
|
||||||
|
mjpegServerFramerate: 10,
|
||||||
|
mjpegScalingFactor: 100,
|
||||||
|
mjpegServerScreenshotQuality: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAG = '[WdaProxyClient]';
|
||||||
|
|
||||||
|
export class WdaProxyClient
|
||||||
|
extends ManagerClient<ParamsWdaProxy, WdaProxyClientEvents>
|
||||||
|
implements TouchHandlerListener {
|
||||||
|
public static calculatePhysicalPoint(
|
||||||
|
screenInfo: ScreenInfo,
|
||||||
|
screenWidth: number,
|
||||||
|
position: Position,
|
||||||
|
): Point | undefined {
|
||||||
|
// ignore the locked video orientation, the events will apply in coordinates considered in the physical device orientation
|
||||||
|
const { videoSize, deviceRotation, contentRect } = screenInfo;
|
||||||
|
const { right, left, bottom, top } = contentRect;
|
||||||
|
let shortSide: number;
|
||||||
|
if (videoSize.width >= videoSize.height) {
|
||||||
|
shortSide = bottom - top;
|
||||||
|
} else {
|
||||||
|
shortSide = right - left;
|
||||||
|
}
|
||||||
|
const scale = shortSide / screenWidth;
|
||||||
|
|
||||||
|
// reverse the video rotation to apply the events
|
||||||
|
const devicePosition = position.rotate(deviceRotation);
|
||||||
|
|
||||||
|
if (!videoSize.equals(devicePosition.screenSize)) {
|
||||||
|
// The client sends a click relative to a video with wrong dimensions,
|
||||||
|
// the device may have been rotated since the event was generated, so ignore the event
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { point } = devicePosition;
|
||||||
|
const convertedX = contentRect.left + (point.x * contentRect.getWidth()) / videoSize.width;
|
||||||
|
const convertedY = contentRect.top + (point.y * contentRect.getHeight()) / videoSize.height;
|
||||||
|
|
||||||
|
const scaledX = Math.round(convertedX / scale);
|
||||||
|
const scaledY = Math.round(convertedY / scale);
|
||||||
|
|
||||||
|
return new Point(scaledX, scaledY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private screenInfo?: ScreenInfo;
|
||||||
|
private screenWidth = 0;
|
||||||
|
private udid: string;
|
||||||
|
private stopped = false;
|
||||||
|
private commands: string[] = [];
|
||||||
|
private hasSession = false;
|
||||||
|
private messageId = 0;
|
||||||
|
private wait: Map<number, { resolve: (m: Message) => void; reject: (error: any) => void }> = new Map();
|
||||||
|
|
||||||
|
constructor(params: ParamsWdaProxy) {
|
||||||
|
super(params);
|
||||||
|
this.openNewConnection();
|
||||||
|
this.udid = params.udid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseParameters(params: URLSearchParams): ParamsWdaProxy {
|
||||||
|
const typedParams = super.parseParameters(params);
|
||||||
|
const { action } = typedParams;
|
||||||
|
if (action !== ACTION.PROXY_WDA) {
|
||||||
|
throw Error('Incorrect action');
|
||||||
|
}
|
||||||
|
return { ...typedParams, action, udid: Util.parseString(params, 'udid', true) };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketClose(event: CloseEvent): void {
|
||||||
|
this.emit('connected', false);
|
||||||
|
console.log(TAG, `Connection closed: ${event.reason}`);
|
||||||
|
if (!this.stopped) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.openNewConnection();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketMessage(event: MessageEvent): void {
|
||||||
|
console.log("接收到的参数03", event.data)
|
||||||
|
|
||||||
|
new Response(event.data)
|
||||||
|
.text()
|
||||||
|
.then((text: string) => {
|
||||||
|
const json = JSON.parse(text) as Message;
|
||||||
|
const id = json['id'];
|
||||||
|
const p = this.wait.get(id);
|
||||||
|
if (p) {
|
||||||
|
this.wait.delete(id);
|
||||||
|
p.resolve(json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (json['type']) {
|
||||||
|
case ControlCenterCommand.RUN_WDA:
|
||||||
|
this.emit('wda-status', json as MessageRunWdaResponse);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw Error('Unsupported message');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
console.error(TAG, error.message);
|
||||||
|
console.log(TAG, event.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketOpen(): void {
|
||||||
|
this.emit('connected', true);
|
||||||
|
while (this.commands.length) {
|
||||||
|
const str = this.commands.shift();
|
||||||
|
if (str) {
|
||||||
|
this.sendCommand(str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendCommand(str: string): void {
|
||||||
|
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
||||||
|
console.log('发送的参数1');
|
||||||
|
this.ws.send(str);
|
||||||
|
} else {
|
||||||
|
this.commands.push(str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNextId(): number {
|
||||||
|
return ++this.messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendMessage(message: Message): Promise<Message> {
|
||||||
|
this.sendCommand(JSON.stringify(message));
|
||||||
|
return new Promise<Message>((resolve, reject) => {
|
||||||
|
this.wait.set(message.id, { resolve, reject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public setScreenInfo(screenInfo: ScreenInfo): void {
|
||||||
|
this.screenInfo = screenInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getScreenInfo(): ScreenInfo | undefined {
|
||||||
|
return this.screenInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getScreenWidth(): Promise<number> {
|
||||||
|
if (this.screenWidth) {
|
||||||
|
return this.screenWidth;
|
||||||
|
}
|
||||||
|
const temp = await this.requestWebDriverAgent(WDAMethod.GET_SCREEN_WIDTH);
|
||||||
|
if (temp.data.success && typeof temp.data.response === 'number') {
|
||||||
|
return (this.screenWidth = temp.data.response);
|
||||||
|
}
|
||||||
|
throw Error('Invalid response');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setMjpegServerOptions(opts: MjpegServerOptions): Promise<void> {
|
||||||
|
const { mjpegServerFramerate, mjpegScalingFactor, mjpegServerScreenshotQuality } = opts;
|
||||||
|
const options: MjpegServerOptions = {
|
||||||
|
mjpegServerFramerate,
|
||||||
|
mjpegScalingFactor,
|
||||||
|
mjpegServerScreenshotQuality,
|
||||||
|
};
|
||||||
|
if (!mjpegServerFramerate || isNaN(mjpegServerFramerate)) {
|
||||||
|
options.mjpegServerFramerate = DefaultMjpegServerOption.mjpegServerFramerate;
|
||||||
|
}
|
||||||
|
if (!mjpegScalingFactor || isNaN(mjpegScalingFactor)) {
|
||||||
|
options.mjpegScalingFactor = DefaultMjpegServerOption.mjpegScalingFactor;
|
||||||
|
}
|
||||||
|
if (!mjpegServerScreenshotQuality || isNaN(mjpegServerScreenshotQuality)) {
|
||||||
|
options.mjpegServerScreenshotQuality = DefaultMjpegServerOption.mjpegServerScreenshotQuality;
|
||||||
|
}
|
||||||
|
return this.requestWebDriverAgent(WDAMethod.APPIUM_SETTINGS, { options });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendKeys(keys: string): Promise<void> {
|
||||||
|
return this.requestWebDriverAgent(WDAMethod.SEND_KEYS, {
|
||||||
|
keys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pressButton(name: string): Promise<void> {
|
||||||
|
return this.requestWebDriverAgent(WDAMethod.PRESS_BUTTON, {
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async performClick(position: Position): Promise<void> {
|
||||||
|
if (!this.screenInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const screenWidth = this.screenWidth || (await this.getScreenWidth());
|
||||||
|
const point = WdaProxyClient.calculatePhysicalPoint(this.screenInfo, screenWidth, position);
|
||||||
|
if (!point) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return this.requestWebDriverAgent(WDAMethod.CLICK, {
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async performScroll(from: Position, to: Position): Promise<void> {
|
||||||
|
if (!this.screenInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wdaScreen = this.screenWidth || (await this.getScreenWidth());
|
||||||
|
const fromPoint = WdaProxyClient.calculatePhysicalPoint(this.screenInfo, wdaScreen, from);
|
||||||
|
const toPoint = WdaProxyClient.calculatePhysicalPoint(this.screenInfo, wdaScreen, to);
|
||||||
|
if (!fromPoint || !toPoint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return this.requestWebDriverAgent(WDAMethod.SCROLL, {
|
||||||
|
from: {
|
||||||
|
x: fromPoint.x,
|
||||||
|
y: fromPoint.y,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
x: toPoint.x,
|
||||||
|
y: toPoint.y,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async runWebDriverAgent(): Promise<MessageRunWdaResponse> {
|
||||||
|
const message: Message = {
|
||||||
|
id: this.getNextId(),
|
||||||
|
type: ControlCenterCommand.RUN_WDA,
|
||||||
|
data: {
|
||||||
|
udid: this.udid,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await this.sendMessage(message);
|
||||||
|
this.hasSession = true;
|
||||||
|
return response as MessageRunWdaResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async requestWebDriverAgent(method: WDAMethod, args?: any): Promise<any> {
|
||||||
|
if (!this.hasSession) {
|
||||||
|
throw Error('No session');
|
||||||
|
}
|
||||||
|
const message: Message = {
|
||||||
|
id: this.getNextId(),
|
||||||
|
type: ControlCenterCommand.REQUEST_WDA,
|
||||||
|
data: {
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected supportMultiplexing(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelInitData(): Buffer {
|
||||||
|
const udid = Util.stringToUtf8ByteArray(this.params.udid);
|
||||||
|
const buffer = Buffer.alloc(4 + 4 + udid.byteLength);
|
||||||
|
buffer.write(ChannelCode.WDAP, 'ascii');
|
||||||
|
buffer.writeUInt32LE(udid.length, 4);
|
||||||
|
buffer.set(udid, 8);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
if (this.stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/app/applDevice/toolbox/ApplMjpegMoreBox.ts
Normal file
81
src/app/applDevice/toolbox/ApplMjpegMoreBox.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { ApplMoreBox } from './ApplMoreBox';
|
||||||
|
import { BasePlayer } from '../../player/BasePlayer';
|
||||||
|
import { DefaultMjpegServerOption, MjpegServerOptions, WdaProxyClient } from '../client/WdaProxyClient';
|
||||||
|
|
||||||
|
export class ApplMjpegMoreBox extends ApplMoreBox {
|
||||||
|
private readonly framerateInput: HTMLInputElement;
|
||||||
|
private readonly scalingFactorInput: HTMLInputElement;
|
||||||
|
private readonly qualityInput: HTMLInputElement;
|
||||||
|
|
||||||
|
constructor(udid: string, player: BasePlayer, wdaConnection: WdaProxyClient) {
|
||||||
|
super(udid, player, wdaConnection);
|
||||||
|
|
||||||
|
const action = 'CHANGE_PARAMS';
|
||||||
|
const text = 'Change stream parameters';
|
||||||
|
const playerName = player.getName();
|
||||||
|
const spoiler = document.createElement('div');
|
||||||
|
const spoilerLabel = document.createElement('label');
|
||||||
|
const spoilerCheck = document.createElement('input');
|
||||||
|
|
||||||
|
const innerDiv = document.createElement('div');
|
||||||
|
const id = `spoiler_video_${udid}_${playerName}_${action}`;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
|
||||||
|
spoiler.className = 'spoiler';
|
||||||
|
spoilerCheck.type = 'checkbox';
|
||||||
|
spoilerCheck.id = id;
|
||||||
|
spoilerLabel.htmlFor = id;
|
||||||
|
spoilerLabel.innerText = text;
|
||||||
|
innerDiv.className = 'box';
|
||||||
|
spoiler.appendChild(spoilerCheck);
|
||||||
|
spoiler.appendChild(spoilerLabel);
|
||||||
|
spoiler.appendChild(innerDiv);
|
||||||
|
const defaultOptions = DefaultMjpegServerOption;
|
||||||
|
|
||||||
|
const framerateLabel = document.createElement('label');
|
||||||
|
framerateLabel.innerText = 'Framerate:';
|
||||||
|
const framerateInput = document.createElement('input');
|
||||||
|
framerateInput.placeholder = `1 .. 60`;
|
||||||
|
framerateInput.value = `${defaultOptions.mjpegServerFramerate}`;
|
||||||
|
ApplMjpegMoreBox.wrap('div', [framerateLabel, framerateInput], innerDiv);
|
||||||
|
this.framerateInput = framerateInput;
|
||||||
|
|
||||||
|
const scalingFactorLabel = document.createElement('label');
|
||||||
|
scalingFactorLabel.innerText = 'Scaling factor:';
|
||||||
|
const scalingFactorInput = document.createElement('input');
|
||||||
|
scalingFactorInput.placeholder = `1 .. 100`;
|
||||||
|
scalingFactorInput.value = `${defaultOptions.mjpegScalingFactor}`;
|
||||||
|
const scalingWrapper = ApplMjpegMoreBox.wrap('div', [scalingFactorLabel, scalingFactorInput], innerDiv);
|
||||||
|
// FIXME: scaling factor changes are not handled
|
||||||
|
scalingWrapper.style.display = 'none';
|
||||||
|
this.scalingFactorInput = scalingFactorInput;
|
||||||
|
|
||||||
|
const qualityLabel = document.createElement('label');
|
||||||
|
qualityLabel.innerText = 'Quality:';
|
||||||
|
const qualityInput = document.createElement('input');
|
||||||
|
qualityInput.placeholder = `1 .. 100`;
|
||||||
|
qualityInput.value = `${defaultOptions.mjpegServerScreenshotQuality}`;
|
||||||
|
ApplMjpegMoreBox.wrap('div', [qualityLabel, qualityInput], innerDiv);
|
||||||
|
this.qualityInput = qualityInput;
|
||||||
|
innerDiv.appendChild(btn);
|
||||||
|
btn.innerText = text;
|
||||||
|
btn.onclick = () => {
|
||||||
|
const mjpegServerFramerate = parseInt(this.framerateInput.value, 10);
|
||||||
|
const mjpegScalingFactor = parseInt(this.scalingFactorInput.value, 10);
|
||||||
|
const mjpegServerScreenshotQuality = parseInt(this.qualityInput.value, 10);
|
||||||
|
const options: MjpegServerOptions = {
|
||||||
|
mjpegServerFramerate,
|
||||||
|
mjpegScalingFactor,
|
||||||
|
mjpegServerScreenshotQuality,
|
||||||
|
};
|
||||||
|
wdaConnection.setMjpegServerOptions(options);
|
||||||
|
};
|
||||||
|
const holder = this.getHolderElement();
|
||||||
|
const childNodes = holder.childNodes;
|
||||||
|
if (childNodes.length > 1) {
|
||||||
|
holder.insertBefore(spoiler, childNodes[childNodes.length - 2]);
|
||||||
|
} else {
|
||||||
|
holder.appendChild(spoiler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/applDevice/toolbox/ApplMoreBox.ts
Normal file
94
src/app/applDevice/toolbox/ApplMoreBox.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import '../../../style/morebox.css';
|
||||||
|
import { BasePlayer } from '../../player/BasePlayer';
|
||||||
|
import Size from '../../Size';
|
||||||
|
import { WdaProxyClient } from '../client/WdaProxyClient';
|
||||||
|
|
||||||
|
const TAG = '[ApplMoreBox]';
|
||||||
|
|
||||||
|
interface StopListener {
|
||||||
|
onStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApplMoreBox {
|
||||||
|
private stopListener?: StopListener;
|
||||||
|
private readonly holder: HTMLElement;
|
||||||
|
|
||||||
|
constructor(udid: string, player: BasePlayer, wdaConnection: WdaProxyClient) {
|
||||||
|
const playerName = player.getName();
|
||||||
|
const moreBox = document.createElement('div');
|
||||||
|
moreBox.className = 'more-box';
|
||||||
|
const nameBox = document.createElement('p');
|
||||||
|
nameBox.innerText = `${udid} (${playerName})`;
|
||||||
|
nameBox.className = 'text-with-shadow';
|
||||||
|
moreBox.appendChild(nameBox);
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.classList.add('text-area');
|
||||||
|
const sendButton = document.createElement('button');
|
||||||
|
sendButton.innerText = 'Send as keys';
|
||||||
|
|
||||||
|
ApplMoreBox.wrap('p', [input, sendButton], moreBox);
|
||||||
|
sendButton.onclick = () => {
|
||||||
|
if (input.value) {
|
||||||
|
wdaConnection.sendKeys(input.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const qualityId = `show_video_quality_${udid}_${playerName}`;
|
||||||
|
const qualityLabel = document.createElement('label');
|
||||||
|
const qualityCheck = document.createElement('input');
|
||||||
|
qualityCheck.type = 'checkbox';
|
||||||
|
qualityCheck.checked = BasePlayer.DEFAULT_SHOW_QUALITY_STATS;
|
||||||
|
qualityCheck.id = qualityId;
|
||||||
|
qualityLabel.htmlFor = qualityId;
|
||||||
|
qualityLabel.innerText = 'Show quality stats';
|
||||||
|
ApplMoreBox.wrap('p', [qualityCheck, qualityLabel], moreBox);
|
||||||
|
qualityCheck.onchange = () => {
|
||||||
|
player.setShowQualityStats(qualityCheck.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = (ev?: string | Event) => {
|
||||||
|
if (ev && ev instanceof Event && ev.type === 'error') {
|
||||||
|
console.error(TAG, ev);
|
||||||
|
}
|
||||||
|
const parent = moreBox.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.removeChild(moreBox);
|
||||||
|
}
|
||||||
|
player.off('video-view-resize', this.onViewVideoResize);
|
||||||
|
if (this.stopListener) {
|
||||||
|
this.stopListener.onStop();
|
||||||
|
delete this.stopListener;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopBtn = document.createElement('button') as HTMLButtonElement;
|
||||||
|
stopBtn.innerText = `Disconnect`;
|
||||||
|
stopBtn.onclick = stop;
|
||||||
|
|
||||||
|
ApplMoreBox.wrap('p', [stopBtn], moreBox);
|
||||||
|
player.on('video-view-resize', this.onViewVideoResize);
|
||||||
|
this.holder = moreBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onViewVideoResize = (size: Size): void => {
|
||||||
|
// padding: 10px
|
||||||
|
this.holder.style.width = `${size.width - 2 * 10}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected static wrap(tagName: string, elements: HTMLElement[], parent: HTMLElement): HTMLElement {
|
||||||
|
const wrap = document.createElement(tagName);
|
||||||
|
elements.forEach((e) => {
|
||||||
|
wrap.appendChild(e);
|
||||||
|
});
|
||||||
|
parent.appendChild(wrap);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHolderElement(): HTMLElement {
|
||||||
|
return this.holder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setOnStop(listener: StopListener): void {
|
||||||
|
this.stopListener = listener;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/app/applDevice/toolbox/ApplToolBox.ts
Normal file
70
src/app/applDevice/toolbox/ApplToolBox.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { ToolBox } from '../../toolbox/ToolBox';
|
||||||
|
import SvgImage from '../../ui/SvgImage';
|
||||||
|
import { BasePlayer } from '../../player/BasePlayer';
|
||||||
|
import { ToolBoxButton } from '../../toolbox/ToolBoxButton';
|
||||||
|
import { ToolBoxElement } from '../../toolbox/ToolBoxElement';
|
||||||
|
import { ToolBoxCheckbox } from '../../toolbox/ToolBoxCheckbox';
|
||||||
|
import { WdaProxyClient } from '../client/WdaProxyClient';
|
||||||
|
|
||||||
|
const BUTTONS = [
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
name: 'home',
|
||||||
|
icon: SvgImage.Icon.HOME,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface StreamClient {
|
||||||
|
getDeviceName(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApplToolBox extends ToolBox {
|
||||||
|
protected constructor(list: ToolBoxElement<any>[]) {
|
||||||
|
super(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createToolBox(
|
||||||
|
udid: string,
|
||||||
|
player: BasePlayer,
|
||||||
|
client: StreamClient,
|
||||||
|
wdaConnection: WdaProxyClient,
|
||||||
|
moreBox?: HTMLElement,
|
||||||
|
): ApplToolBox {
|
||||||
|
const playerName = player.getName();
|
||||||
|
const list = BUTTONS.slice();
|
||||||
|
const handler = <K extends keyof HTMLElementEventMap, T extends HTMLElement>(
|
||||||
|
_: K,
|
||||||
|
element: ToolBoxElement<T>,
|
||||||
|
) => {
|
||||||
|
if (!element.optional?.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { name } = element.optional;
|
||||||
|
wdaConnection.pressButton(name);
|
||||||
|
};
|
||||||
|
const elements: ToolBoxElement<any>[] = list.map((item) => {
|
||||||
|
const button = new ToolBoxButton(item.title, item.icon, {
|
||||||
|
name: item.name,
|
||||||
|
});
|
||||||
|
button.addEventListener('click', handler);
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
if (player.supportsScreenshot) {
|
||||||
|
const screenshot = new ToolBoxButton('Take screenshot', SvgImage.Icon.CAMERA);
|
||||||
|
screenshot.addEventListener('click', () => {
|
||||||
|
player.createScreenshot(client.getDeviceName());
|
||||||
|
});
|
||||||
|
elements.push(screenshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moreBox) {
|
||||||
|
const more = new ToolBoxCheckbox('More', SvgImage.Icon.MORE, `show_more_${udid}_${playerName}`);
|
||||||
|
more.addEventListener('click', (_, el) => {
|
||||||
|
const element = el.getElement();
|
||||||
|
moreBox.style.display = element.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
elements.unshift(more);
|
||||||
|
}
|
||||||
|
return new ApplToolBox(elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/app/client/BaseClient.ts
Normal file
40
src/app/client/BaseClient.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { EventMap, TypedEmitter } from '../../common/TypedEmitter';
|
||||||
|
import { ParamsBase } from '../../types/ParamsBase';
|
||||||
|
import Util from '../Util';
|
||||||
|
|
||||||
|
export class BaseClient<P extends ParamsBase, TE extends EventMap> extends TypedEmitter<TE> {
|
||||||
|
protected title = 'BaseClient';
|
||||||
|
protected params: P;
|
||||||
|
|
||||||
|
protected constructor(params: P) {
|
||||||
|
super();
|
||||||
|
this.params = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseParameters(query: URLSearchParams): ParamsBase {
|
||||||
|
const action = Util.parseStringEnv(query.get('action'));
|
||||||
|
if (!action) {
|
||||||
|
throw TypeError('Invalid action');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
action: action,
|
||||||
|
useProxy: Util.parseBooleanEnv(query.get('useProxy')),
|
||||||
|
secure: Util.parseBooleanEnv(query.get('secure')),
|
||||||
|
hostname: Util.parseStringEnv(query.get('hostname')),
|
||||||
|
port: Util.parseIntEnv(query.get('port')),
|
||||||
|
pathname: Util.parseStringEnv(query.get('pathname')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTitle(text = this.title): void {
|
||||||
|
let titleTag: HTMLTitleElement | null = document.querySelector('head > title');
|
||||||
|
if (!titleTag) {
|
||||||
|
titleTag = document.createElement('title');
|
||||||
|
}
|
||||||
|
titleTag.innerText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setBodyClass(text: string): void {
|
||||||
|
document.body.className = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
280
src/app/client/BaseDeviceTracker.ts
Normal file
280
src/app/client/BaseDeviceTracker.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { ManagerClient } from './ManagerClient';
|
||||||
|
import { Message } from '../../types/Message';
|
||||||
|
import { BaseDeviceDescriptor } from '../../types/BaseDeviceDescriptor';
|
||||||
|
import { DeviceTrackerEvent } from '../../types/DeviceTrackerEvent';
|
||||||
|
import { DeviceTrackerEventList } from '../../types/DeviceTrackerEventList';
|
||||||
|
import { html } from '../ui/HtmlTag';
|
||||||
|
import { ParamsDeviceTracker } from '../../types/ParamsDeviceTracker';
|
||||||
|
import { HostItem } from '../../types/Configuration';
|
||||||
|
import { Tool } from './Tool';
|
||||||
|
import Util from '../Util';
|
||||||
|
import { EventMap } from '../../common/TypedEmitter';
|
||||||
|
|
||||||
|
const TAG = '[BaseDeviceTracker]';
|
||||||
|
|
||||||
|
export abstract class BaseDeviceTracker<DD extends BaseDeviceDescriptor, TE extends EventMap> extends ManagerClient<
|
||||||
|
ParamsDeviceTracker,
|
||||||
|
TE
|
||||||
|
> {
|
||||||
|
public static readonly ACTION_LIST = 'devicelist';
|
||||||
|
public static readonly ACTION_DEVICE = 'device';
|
||||||
|
public static readonly HOLDER_ELEMENT_ID = 'devices';
|
||||||
|
public static readonly AttributePrefixInterfaceSelectFor = 'interface_select_for_';
|
||||||
|
public static readonly AttributePlayerFullName = 'data-player-full-name';
|
||||||
|
public static readonly AttributePlayerCodeName = 'data-player-code-name';
|
||||||
|
public static readonly AttributePrefixPlayerFor = 'player_for_';
|
||||||
|
protected static tools: Set<Tool> = new Set();
|
||||||
|
protected static instanceId = 0;
|
||||||
|
|
||||||
|
public static registerTool(tool: Tool): void {
|
||||||
|
this.tools.add(tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static buildUrl(item: HostItem): URL {
|
||||||
|
const { secure, port, hostname } = item;
|
||||||
|
const pathname = item.pathname ?? '/';
|
||||||
|
const protocol = secure ? 'wss:' : 'ws:';
|
||||||
|
const url = new URL(`${protocol}//${hostname}${pathname}`);
|
||||||
|
if (port) {
|
||||||
|
url.port = port.toString();
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static buildUrlForTracker(params: HostItem): URL {
|
||||||
|
const wsUrl = this.buildUrl(params);
|
||||||
|
wsUrl.searchParams.set('action', this.ACTION);
|
||||||
|
return wsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static buildLink(q: any, text: string, params: ParamsDeviceTracker): HTMLAnchorElement {
|
||||||
|
let { hostname } = params;
|
||||||
|
let port: string | number | undefined = params.port;
|
||||||
|
let pathname = params.pathname ?? location.pathname;
|
||||||
|
let protocol = params.secure ? 'https:' : 'http:';
|
||||||
|
if (params.useProxy) {
|
||||||
|
q.hostname = hostname;
|
||||||
|
q.port = port;
|
||||||
|
q.pathname = pathname;
|
||||||
|
q.secure = params.secure;
|
||||||
|
q.useProxy = true;
|
||||||
|
protocol = location.protocol;
|
||||||
|
hostname = location.hostname;
|
||||||
|
port = location.port;
|
||||||
|
pathname = location.pathname;
|
||||||
|
}
|
||||||
|
const hash = `#!${new URLSearchParams(q).toString()}`;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.setAttribute('href', `${protocol}//${hostname}:${port}${pathname}${hash}`);
|
||||||
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
a.classList.add(`link-${q.action}`);
|
||||||
|
a.innerText = text;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected title = 'Device list';
|
||||||
|
protected tableId = 'base_device_list';
|
||||||
|
protected descriptors: DD[] = [];
|
||||||
|
protected elementId: string;
|
||||||
|
protected trackerName = '';
|
||||||
|
protected id = '';
|
||||||
|
private created = false;
|
||||||
|
private messageId = 0;
|
||||||
|
|
||||||
|
protected constructor(params: ParamsDeviceTracker, protected readonly directUrl: string) {
|
||||||
|
super(params);
|
||||||
|
this.elementId = `tracker_instance${++BaseDeviceTracker.instanceId}`;
|
||||||
|
this.trackerName = `Unavailable. Host: ${params.hostname}, type: ${params.type}`;
|
||||||
|
this.setBodyClass('list');
|
||||||
|
this.setTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseParameters(params: URLSearchParams): ParamsDeviceTracker {
|
||||||
|
const typedParams = super.parseParameters(params);
|
||||||
|
const type = Util.parseString(params, 'type', true);
|
||||||
|
if (type !== 'android' && type !== 'ios') {
|
||||||
|
throw Error('Incorrect type');
|
||||||
|
}
|
||||||
|
return { ...typedParams, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getNextId(): number {
|
||||||
|
return ++this.messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildDeviceTable(): void {
|
||||||
|
const data = this.descriptors;
|
||||||
|
const devices = this.getOrCreateTableHolder();
|
||||||
|
const tbody = this.getOrBuildTableBody(devices);
|
||||||
|
|
||||||
|
const block = this.getOrCreateTrackerBlock(tbody, this.trackerName);
|
||||||
|
data.forEach((item) => {
|
||||||
|
this.buildDeviceRow(block, item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setNameValue(parent: Element | null, name: string): void {
|
||||||
|
if (!parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nameBlockId = `${this.elementId}_name`;
|
||||||
|
let nameEl = document.getElementById(nameBlockId);
|
||||||
|
if (!nameEl) {
|
||||||
|
nameEl = document.createElement('div');
|
||||||
|
nameEl.id = nameBlockId;
|
||||||
|
nameEl.className = 'tracker-name';
|
||||||
|
}
|
||||||
|
nameEl.innerText = name;
|
||||||
|
parent.insertBefore(nameEl, parent.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreateTrackerBlock(parent: Element, controlCenterName: string): Element {
|
||||||
|
let el = document.getElementById(this.elementId);
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.id = this.elementId;
|
||||||
|
parent.appendChild(el);
|
||||||
|
this.created = true;
|
||||||
|
} else {
|
||||||
|
while (el.children.length) {
|
||||||
|
el.removeChild(el.children[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setNameValue(el, controlCenterName);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract buildDeviceRow(tbody: Element, device: DD): void;
|
||||||
|
|
||||||
|
protected onSocketClose(event: CloseEvent): void {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(TAG, `Connection closed: ${event.reason}`);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.openNewConnection();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketMessage(event: MessageEvent): void {
|
||||||
|
console.log("接收到的参数3", event.data)
|
||||||
|
|
||||||
|
let message: Message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(TAG, error.message);
|
||||||
|
console.log(TAG, error.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (message.type) {
|
||||||
|
case BaseDeviceTracker.ACTION_LIST: {
|
||||||
|
const event = message.data as DeviceTrackerEventList<DD>;
|
||||||
|
this.descriptors = event.list;
|
||||||
|
this.setIdAndHostName(event.id, event.name);
|
||||||
|
this.buildDeviceTable();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case BaseDeviceTracker.ACTION_DEVICE: {
|
||||||
|
const event = message.data as DeviceTrackerEvent<DD>;
|
||||||
|
this.setIdAndHostName(event.id, event.name);
|
||||||
|
this.updateDescriptor(event.device);
|
||||||
|
this.buildDeviceTable();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.log(TAG, `Unknown message type: ${message.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setIdAndHostName(id: string, trackerName: string): void {
|
||||||
|
if (this.id === id && this.trackerName === trackerName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.id = id;
|
||||||
|
this.trackerName = trackerName;
|
||||||
|
this.setNameValue(document.getElementById(this.elementId), trackerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getOrCreateTableHolder(): HTMLElement {
|
||||||
|
const id = BaseDeviceTracker.HOLDER_ELEMENT_ID;
|
||||||
|
let devices = document.getElementById(id);
|
||||||
|
if (!devices) {
|
||||||
|
devices = document.createElement('div');
|
||||||
|
devices.id = id;
|
||||||
|
devices.className = 'table-wrapper';
|
||||||
|
document.body.appendChild(devices);
|
||||||
|
}
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateDescriptor(descriptor: DD): void {
|
||||||
|
const idx = this.descriptors.findIndex((item: DD) => {
|
||||||
|
return item.udid === descriptor.udid;
|
||||||
|
});
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.descriptors[idx] = descriptor;
|
||||||
|
} else {
|
||||||
|
this.descriptors.push(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getOrBuildTableBody(parent: HTMLElement): Element {
|
||||||
|
const className = 'device-list';
|
||||||
|
let tbody = document.querySelector(
|
||||||
|
`#${BaseDeviceTracker.HOLDER_ELEMENT_ID} #${this.tableId}.${className}`,
|
||||||
|
) as Element;
|
||||||
|
if (!tbody) {
|
||||||
|
const fragment = html`<div id="${this.tableId}" class="${className}"></div>`.content;
|
||||||
|
parent.appendChild(fragment);
|
||||||
|
const last = parent.children.item(parent.children.length - 1);
|
||||||
|
if (last) {
|
||||||
|
tbody = last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tbody;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDescriptorByUdid(udid: string): DD | undefined {
|
||||||
|
if (!this.descriptors.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return this.descriptors.find((descriptor: DD) => {
|
||||||
|
return descriptor.udid === udid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
super.destroy();
|
||||||
|
if (this.created) {
|
||||||
|
const el = document.getElementById(this.elementId);
|
||||||
|
if (el) {
|
||||||
|
const { parentElement } = el;
|
||||||
|
el.remove();
|
||||||
|
if (parentElement && !parentElement.children.length) {
|
||||||
|
parentElement.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const holder = document.getElementById(BaseDeviceTracker.HOLDER_ELEMENT_ID);
|
||||||
|
if (holder && !holder.children.length) {
|
||||||
|
holder.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected supportMultiplexing(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelCode(): string {
|
||||||
|
throw Error('Not implemented. Must override');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelInitData(): Buffer {
|
||||||
|
const code = this.getChannelCode();
|
||||||
|
const buffer = Buffer.alloc(code.length);
|
||||||
|
buffer.write(code, 'ascii');
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/app/client/HostTracker.ts
Normal file
127
src/app/client/HostTracker.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { ManagerClient } from './ManagerClient';
|
||||||
|
import { Message } from '../../types/Message';
|
||||||
|
import { MessageError, MessageHosts, MessageType } from '../../common/HostTrackerMessage';
|
||||||
|
import { ACTION } from '../../common/Action';
|
||||||
|
import { DeviceTracker as GoogDeviceTracker } from '../googDevice/client/DeviceTracker';
|
||||||
|
import { DeviceTracker as ApplDeviceTracker } from '../applDevice/client/DeviceTracker';
|
||||||
|
import { ParamsBase } from '../../types/ParamsBase';
|
||||||
|
import { HostItem } from '../../types/Configuration';
|
||||||
|
import { ChannelCode } from '../../common/ChannelCode';
|
||||||
|
|
||||||
|
const TAG = '[HostTracker]';
|
||||||
|
|
||||||
|
export interface HostTrackerEvents {
|
||||||
|
// hosts: HostItem[];
|
||||||
|
disconnected: CloseEvent;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HostTracker extends ManagerClient<ParamsBase, HostTrackerEvents> {
|
||||||
|
private static instance?: HostTracker;
|
||||||
|
|
||||||
|
public static start(): void {
|
||||||
|
this.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): HostTracker {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new HostTracker();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackers: Array<GoogDeviceTracker | ApplDeviceTracker> = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ action: ACTION.LIST_HOSTS });
|
||||||
|
this.openNewConnection();
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketClose(ev: CloseEvent): void {
|
||||||
|
console.log(TAG, 'WS closed');
|
||||||
|
this.emit('disconnected', ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketMessage(event: MessageEvent): void {
|
||||||
|
console.log("接收到的参数4", event.data)
|
||||||
|
|
||||||
|
let message: Message;
|
||||||
|
try {
|
||||||
|
// TODO: rewrite to binary
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(TAG, error.message);
|
||||||
|
console.log(TAG, error.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (message.type) {
|
||||||
|
case MessageType.ERROR: {
|
||||||
|
const msg = message as MessageError;
|
||||||
|
console.error(TAG, msg.data);
|
||||||
|
this.emit('error', msg.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MessageType.HOSTS: {
|
||||||
|
const msg = message as MessageHosts;
|
||||||
|
// this.emit('hosts', msg.data);
|
||||||
|
if (msg.data.local) {
|
||||||
|
msg.data.local.forEach(({ type }) => {
|
||||||
|
const secure = location.protocol === 'https:';
|
||||||
|
const port = location.port ? parseInt(location.port, 10) : secure ? 443 : 80;
|
||||||
|
const { hostname, pathname } = location;
|
||||||
|
if (type !== 'android' && type !== 'ios') {
|
||||||
|
console.warn(TAG, `Unsupported host type: "${type}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hostItem: HostItem = { useProxy: false, secure, port, hostname, pathname, type };
|
||||||
|
this.startTracker(hostItem);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (msg.data.remote) {
|
||||||
|
msg.data.remote.forEach((item) => this.startTracker(item));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.log(TAG, `Unknown message type: ${message.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTracker(hostItem: HostItem): void {
|
||||||
|
switch (hostItem.type) {
|
||||||
|
case 'android':
|
||||||
|
this.trackers.push(GoogDeviceTracker.start(hostItem));
|
||||||
|
break;
|
||||||
|
case 'ios':
|
||||||
|
this.trackers.push(ApplDeviceTracker.start(hostItem));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(TAG, `Unsupported host type: "${hostItem.type}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketOpen(): void {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
super.destroy();
|
||||||
|
this.trackers.forEach((tracker) => {
|
||||||
|
tracker.destroy();
|
||||||
|
});
|
||||||
|
this.trackers.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected supportMultiplexing(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelInitData(): Buffer {
|
||||||
|
const buffer = Buffer.alloc(4);
|
||||||
|
buffer.write(ChannelCode.HSTS, 'ascii');
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/app/client/ManagerClient.ts
Normal file
133
src/app/client/ManagerClient.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { BaseClient } from './BaseClient';
|
||||||
|
import { ACTION } from '../../common/Action';
|
||||||
|
import { ParamsBase } from '../../types/ParamsBase';
|
||||||
|
import Util from '../Util';
|
||||||
|
import { Multiplexer } from '../../packages/multiplexer/Multiplexer';
|
||||||
|
import { EventMap } from '../../common/TypedEmitter';
|
||||||
|
|
||||||
|
export abstract class ManagerClient<P extends ParamsBase, TE extends EventMap> extends BaseClient<P, TE> {
|
||||||
|
public static ACTION = 'unknown';
|
||||||
|
public static CODE = 'NONE';
|
||||||
|
public static sockets: Map<string, Multiplexer> = new Map();
|
||||||
|
protected destroyed = false;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
|
||||||
|
public static start(..._rest: any[]): void {
|
||||||
|
throw Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly action?: string;
|
||||||
|
protected url: URL;
|
||||||
|
protected ws?: Multiplexer | WebSocket;
|
||||||
|
|
||||||
|
protected constructor(params: P) {
|
||||||
|
super(params);
|
||||||
|
this.action = Util.parseStringEnv(params.action);
|
||||||
|
this.url = this.buildWebSocketUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasConnection(): boolean {
|
||||||
|
return !!(this.ws && this.ws.readyState === this.ws.OPEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openNewConnection(): Multiplexer | WebSocket {
|
||||||
|
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
||||||
|
this.ws.close();
|
||||||
|
delete this.ws;
|
||||||
|
}
|
||||||
|
const url = this.url.toString();
|
||||||
|
console.log(`连接到的websocket链接 ${url}`);
|
||||||
|
if (this.supportMultiplexing()) {
|
||||||
|
let openedMultiplexer = ManagerClient.sockets.get(url);
|
||||||
|
if (!openedMultiplexer) {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
ManagerClient.sockets.delete(url);
|
||||||
|
});
|
||||||
|
const newMultiplexer = Multiplexer.wrap(ws);
|
||||||
|
newMultiplexer.on('empty', () => {
|
||||||
|
newMultiplexer.close();
|
||||||
|
});
|
||||||
|
ManagerClient.sockets.set(url, newMultiplexer);
|
||||||
|
openedMultiplexer = newMultiplexer;
|
||||||
|
}
|
||||||
|
const ws = openedMultiplexer.createChannel(this.getChannelInitData());
|
||||||
|
ws.addEventListener('open', this.onSocketOpen.bind(this));
|
||||||
|
ws.addEventListener('message', this.onSocketMessage.bind(this));
|
||||||
|
ws.addEventListener('close', this.onSocketClose.bind(this));
|
||||||
|
this.ws = ws;
|
||||||
|
} else {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
ws.addEventListener('open', this.onSocketOpen.bind(this));
|
||||||
|
ws.addEventListener('message', this.onSocketMessage.bind(this));
|
||||||
|
ws.addEventListener('close', this.onSocketClose.bind(this));
|
||||||
|
this.ws = ws;
|
||||||
|
}
|
||||||
|
return this.ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
if (this.destroyed) {
|
||||||
|
console.error(new Error('Already disposed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.destroyed = true;
|
||||||
|
if (this.ws) {
|
||||||
|
if (this.ws.readyState === this.ws.OPEN) {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildWebSocketUrl(): URL {
|
||||||
|
const directUrl = this.buildDirectWebSocketUrl();
|
||||||
|
if (this.params.useProxy && !this.supportMultiplexing()) {
|
||||||
|
return this.wrapInProxy(directUrl);
|
||||||
|
}
|
||||||
|
return directUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildDirectWebSocketUrl(): URL {
|
||||||
|
const { hostname, port, secure, action } = this.params;
|
||||||
|
const pathname = this.params.pathname ?? location.pathname;
|
||||||
|
let urlString: string;
|
||||||
|
if (typeof hostname === 'string' && typeof port === 'number') {
|
||||||
|
const protocol = secure ? 'wss:' : 'ws:';
|
||||||
|
urlString = `${protocol}//${hostname}:${port}${pathname}`;
|
||||||
|
} else {
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
|
||||||
|
// location.host includes hostname and port
|
||||||
|
urlString = `${protocol}${location.host}${pathname}`;
|
||||||
|
}
|
||||||
|
const directUrl = new URL(urlString);
|
||||||
|
if (this.supportMultiplexing()) {
|
||||||
|
directUrl.searchParams.set('action', ACTION.MULTIPLEX);
|
||||||
|
} else {
|
||||||
|
if (action) {
|
||||||
|
directUrl.searchParams.set('action', action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return directUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected wrapInProxy(directUrl: URL): URL {
|
||||||
|
const localProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const localUrl = new URL(`${localProtocol}//${location.host}`);
|
||||||
|
localUrl.searchParams.set('action', ACTION.PROXY_WS);
|
||||||
|
localUrl.searchParams.set('ws', directUrl.toString());
|
||||||
|
return localUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected supportMultiplexing(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelInitData(): Buffer {
|
||||||
|
return Buffer.from(ManagerClient.CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract onSocketOpen(event: Event): void;
|
||||||
|
protected abstract onSocketMessage(event: MessageEvent): void;
|
||||||
|
protected abstract onSocketClose(event: CloseEvent): void;
|
||||||
|
}
|
||||||
252
src/app/client/StreamReceiver.ts
Normal file
252
src/app/client/StreamReceiver.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { ManagerClient } from './ManagerClient';
|
||||||
|
import { ControlMessage } from '../controlMessage/ControlMessage';
|
||||||
|
import DeviceMessage from '../googDevice/DeviceMessage';
|
||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import ScreenInfo from '../ScreenInfo';
|
||||||
|
import Util from '../Util';
|
||||||
|
import { DisplayInfo } from '../DisplayInfo';
|
||||||
|
import { ParamsStream } from '../../types/ParamsStream';
|
||||||
|
// import WebSocket, { Server } from 'ws'; // 新增WebSocket服务端依赖
|
||||||
|
|
||||||
|
const DEVICE_NAME_FIELD_LENGTH = 64;
|
||||||
|
const MAGIC_BYTES_INITIAL = Util.stringToUtf8ByteArray('scrcpy_initial');
|
||||||
|
|
||||||
|
export type ClientsStats = {
|
||||||
|
deviceName: string;
|
||||||
|
clientId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DisplayCombinedInfo = {
|
||||||
|
displayInfo: DisplayInfo;
|
||||||
|
videoSettings?: VideoSettings;
|
||||||
|
screenInfo?: ScreenInfo;
|
||||||
|
connectionCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StreamReceiverEvents {
|
||||||
|
video: ArrayBuffer;
|
||||||
|
deviceMessage: DeviceMessage;
|
||||||
|
displayInfo: DisplayCombinedInfo[];
|
||||||
|
clientsStats: ClientsStats;
|
||||||
|
encoders: string[];
|
||||||
|
connected: void;
|
||||||
|
disconnected: CloseEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG = '[StreamReceiver]';
|
||||||
|
|
||||||
|
export class StreamReceiver<P extends ParamsStream> extends ManagerClient<ParamsStream, StreamReceiverEvents> {
|
||||||
|
// 新增WebSocket服务端相关属性
|
||||||
|
// private wss: Server | null = null;
|
||||||
|
// private clientConnections: Set<WebSocket> = new Set();
|
||||||
|
|
||||||
|
private events: ControlMessage[] = [];
|
||||||
|
private encodersSet: Set<string> = new Set<string>();
|
||||||
|
private clientId = -1;
|
||||||
|
private deviceName = '';
|
||||||
|
private readonly displayInfoMap: Map<number, DisplayInfo> = new Map();
|
||||||
|
private readonly connectionCountMap: Map<number, number> = new Map();
|
||||||
|
private readonly screenInfoMap: Map<number, ScreenInfo> = new Map();
|
||||||
|
private readonly videoSettingsMap: Map<number, VideoSettings> = new Map();
|
||||||
|
private hasInitialInfo = false;
|
||||||
|
|
||||||
|
constructor(params: P) {
|
||||||
|
super(params);
|
||||||
|
|
||||||
|
// 新增WebSocket服务端初始化
|
||||||
|
// this.startWebSocketServer();
|
||||||
|
|
||||||
|
this.openNewConnection();
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 新增方法:启动WebSocket服务端
|
||||||
|
// private startWebSocketServer(): void {
|
||||||
|
// this.wss = new WebSocket.Server({ port: 8765 });
|
||||||
|
|
||||||
|
// this.wss.on('connection', (ws: WebSocket) => {
|
||||||
|
// console.log(`连接成功`);
|
||||||
|
// this.clientConnections.add(ws);
|
||||||
|
|
||||||
|
// // 设置消息处理器
|
||||||
|
// ws.on('message', (data: WebSocket.Data) => {
|
||||||
|
// console.log(data, '接收到')
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // 处理连接关闭
|
||||||
|
// ws.on('close', () => {
|
||||||
|
// console.log(`${TAG} Client disconnected from server`);
|
||||||
|
// this.clientConnections.delete(ws);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // 发送初始信息给新客户端
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
private handleInitialInfo(data: ArrayBuffer): void {
|
||||||
|
let offset = MAGIC_BYTES_INITIAL.length;
|
||||||
|
let nameBytes = new Uint8Array(data, offset, DEVICE_NAME_FIELD_LENGTH);
|
||||||
|
offset += DEVICE_NAME_FIELD_LENGTH;
|
||||||
|
let rest: Buffer = Buffer.from(new Uint8Array(data, offset));
|
||||||
|
const displaysCount = rest.readInt32BE(0);
|
||||||
|
this.displayInfoMap.clear();
|
||||||
|
this.connectionCountMap.clear();
|
||||||
|
this.screenInfoMap.clear();
|
||||||
|
this.videoSettingsMap.clear();
|
||||||
|
rest = rest.slice(4);
|
||||||
|
for (let i = 0; i < displaysCount; i++) {
|
||||||
|
const displayInfoBuffer = rest.slice(0, DisplayInfo.BUFFER_LENGTH);
|
||||||
|
const displayInfo = DisplayInfo.fromBuffer(displayInfoBuffer);
|
||||||
|
const { displayId } = displayInfo;
|
||||||
|
this.displayInfoMap.set(displayId, displayInfo);
|
||||||
|
rest = rest.slice(DisplayInfo.BUFFER_LENGTH);
|
||||||
|
this.connectionCountMap.set(displayId, rest.readInt32BE(0));
|
||||||
|
rest = rest.slice(4);
|
||||||
|
const screenInfoBytesCount = rest.readInt32BE(0);
|
||||||
|
rest = rest.slice(4);
|
||||||
|
if (screenInfoBytesCount) {
|
||||||
|
this.screenInfoMap.set(displayId, ScreenInfo.fromBuffer(rest.slice(0, screenInfoBytesCount)));
|
||||||
|
rest = rest.slice(screenInfoBytesCount);
|
||||||
|
}
|
||||||
|
const videoSettingsBytesCount = rest.readInt32BE(0);
|
||||||
|
rest = rest.slice(4);
|
||||||
|
if (videoSettingsBytesCount) {
|
||||||
|
this.videoSettingsMap.set(displayId, VideoSettings.fromBuffer(rest.slice(0, videoSettingsBytesCount)));
|
||||||
|
rest = rest.slice(videoSettingsBytesCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.encodersSet.clear();
|
||||||
|
const encodersCount = rest.readInt32BE(0);
|
||||||
|
rest = rest.slice(4);
|
||||||
|
for (let i = 0; i < encodersCount; i++) {
|
||||||
|
const nameLength = rest.readInt32BE(0);
|
||||||
|
rest = rest.slice(4);
|
||||||
|
const nameBytes = rest.slice(0, nameLength);
|
||||||
|
rest = rest.slice(nameLength);
|
||||||
|
const name = Util.utf8ByteArrayToString(nameBytes);
|
||||||
|
this.encodersSet.add(name);
|
||||||
|
}
|
||||||
|
this.clientId = rest.readInt32BE(0);
|
||||||
|
nameBytes = Util.filterTrailingZeroes(nameBytes);
|
||||||
|
this.deviceName = Util.utf8ByteArrayToString(nameBytes);
|
||||||
|
this.hasInitialInfo = true;
|
||||||
|
this.triggerInitialInfoEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EqualArrays(a: ArrayLike<number>, b: ArrayLike<number>): boolean {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0, l = a.length; i < l; i++) {
|
||||||
|
if (a[i] !== b[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildDirectWebSocketUrl(): URL {
|
||||||
|
const localUrl = super.buildDirectWebSocketUrl();
|
||||||
|
if (this.supportMultiplexing()) {
|
||||||
|
return localUrl;
|
||||||
|
}
|
||||||
|
localUrl.searchParams.set('udid', this.params.udid);
|
||||||
|
return localUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketClose(ev: CloseEvent): void {
|
||||||
|
console.log(`${TAG}. WS closed: ${ev.reason}`);
|
||||||
|
this.emit('disconnected', ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketMessage(event: MessageEvent): void {
|
||||||
|
// console.log("屏幕接收到的参数", event.data)
|
||||||
|
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
|
||||||
|
// works only because MAGIC_BYTES_INITIAL and MAGIC_BYTES_MESSAGE have same length
|
||||||
|
if (event.data.byteLength > MAGIC_BYTES_INITIAL.length) {
|
||||||
|
const magicBytes = new Uint8Array(event.data, 0, MAGIC_BYTES_INITIAL.length);
|
||||||
|
if (StreamReceiver.EqualArrays(magicBytes, MAGIC_BYTES_INITIAL)) {
|
||||||
|
this.handleInitialInfo(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (StreamReceiver.EqualArrays(magicBytes, DeviceMessage.MAGIC_BYTES_MESSAGE)) {
|
||||||
|
const message = DeviceMessage.fromBuffer(event.data);
|
||||||
|
console.log('屏幕接收到22', message)
|
||||||
|
this.emit('deviceMessage', message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('video', new Uint8Array(event.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketOpen(): void {
|
||||||
|
this.emit('connected', void 0);
|
||||||
|
let e = this.events.shift();
|
||||||
|
while (e) {
|
||||||
|
this.sendEvent(e);
|
||||||
|
e = this.events.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendEvent(event: ControlMessage): void {
|
||||||
|
|
||||||
|
console.log('发送消息2', event)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
||||||
|
// console.log('发送的参数2');
|
||||||
|
|
||||||
|
this.ws.send(event.toBuffer());
|
||||||
|
} else {
|
||||||
|
// this.events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
this.events.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEncoders(): string[] {
|
||||||
|
return Array.from(this.encodersSet.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDeviceName(): string {
|
||||||
|
return this.deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public triggerInitialInfoEvents(): void {
|
||||||
|
if (this.hasInitialInfo) {
|
||||||
|
const encoders = this.getEncoders();
|
||||||
|
this.emit('encoders', encoders);
|
||||||
|
const { clientId, deviceName } = this;
|
||||||
|
this.emit('clientsStats', { clientId, deviceName });
|
||||||
|
const infoArray: DisplayCombinedInfo[] = [];
|
||||||
|
this.displayInfoMap.forEach((displayInfo: DisplayInfo, displayId: number) => {
|
||||||
|
const connectionCount = this.connectionCountMap.get(displayId) || 0;
|
||||||
|
infoArray.push({
|
||||||
|
displayInfo,
|
||||||
|
videoSettings: this.videoSettingsMap.get(displayId),
|
||||||
|
screenInfo: this.screenInfoMap.get(displayId),
|
||||||
|
connectionCount,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.emit('displayInfo', infoArray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDisplayInfo(displayId: number): DisplayInfo | undefined {
|
||||||
|
return this.displayInfoMap.get(displayId);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/app/client/Tool.d.ts
vendored
Normal file
12
src/app/client/Tool.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ParamsDeviceTracker } from '../../types/ParamsDeviceTracker';
|
||||||
|
import { BaseDeviceDescriptor } from '../../types/BaseDeviceDescriptor';
|
||||||
|
|
||||||
|
type Entry = HTMLElement | DocumentFragment;
|
||||||
|
|
||||||
|
export interface Tool {
|
||||||
|
createEntryForDeviceList(
|
||||||
|
descriptor: BaseDeviceDescriptor,
|
||||||
|
blockClass: string,
|
||||||
|
params: ParamsDeviceTracker,
|
||||||
|
): Array<Entry | undefined> | Entry | undefined;
|
||||||
|
}
|
||||||
226
src/app/controlMessage/CommandControlMessage.ts
Normal file
226
src/app/controlMessage/CommandControlMessage.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { ControlMessage } from './ControlMessage';
|
||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import Util from '../Util';
|
||||||
|
|
||||||
|
export enum FilePushState {
|
||||||
|
NEW,
|
||||||
|
START,
|
||||||
|
APPEND,
|
||||||
|
FINISH,
|
||||||
|
CANCEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilePushParams = {
|
||||||
|
id: number;
|
||||||
|
state: FilePushState;
|
||||||
|
chunk?: Uint8Array;
|
||||||
|
fileName?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CommandControlMessage extends ControlMessage {
|
||||||
|
public static PAYLOAD_LENGTH = 0;
|
||||||
|
|
||||||
|
public static Commands: Map<number, string> = new Map([
|
||||||
|
[ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL, 'Expand notifications'],
|
||||||
|
[ControlMessage.TYPE_EXPAND_SETTINGS_PANEL, 'Expand settings'],
|
||||||
|
[ControlMessage.TYPE_COLLAPSE_PANELS, 'Collapse panels'],
|
||||||
|
[ControlMessage.TYPE_GET_CLIPBOARD, 'Get clipboard'],
|
||||||
|
[ControlMessage.TYPE_SET_CLIPBOARD, 'Set clipboard'],
|
||||||
|
[ControlMessage.TYPE_ROTATE_DEVICE, 'Rotate device'],
|
||||||
|
[ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS, 'Change video settings'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
public static createSetVideoSettingsCommand(videoSettings: VideoSettings): CommandControlMessage {
|
||||||
|
const temp = videoSettings.toBuffer();
|
||||||
|
const event = new CommandControlMessage(ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS);
|
||||||
|
const offset = CommandControlMessage.PAYLOAD_LENGTH + 1;
|
||||||
|
const buffer = Buffer.alloc(offset + temp.length);
|
||||||
|
buffer.writeUInt8(event.type, 0);
|
||||||
|
temp.forEach((byte, index) => {
|
||||||
|
buffer.writeUInt8(byte, index + offset);
|
||||||
|
});
|
||||||
|
event.buffer = buffer;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createSetClipboardCommand(text: string, paste = true): CommandControlMessage {
|
||||||
|
const event = new CommandControlMessage(ControlMessage.TYPE_SET_CLIPBOARD);
|
||||||
|
const textBytes: Uint8Array | null = text ? Util.stringToUtf8ByteArray(text) : null;
|
||||||
|
const textLength = textBytes ? textBytes.length : 0;
|
||||||
|
let offset = 0;
|
||||||
|
const buffer = Buffer.alloc(1 + 1 + 4 + textLength);
|
||||||
|
offset = buffer.writeInt8(event.type, offset);
|
||||||
|
offset = buffer.writeInt8(paste ? 1 : 0, offset);
|
||||||
|
offset = buffer.writeInt32BE(textLength, offset);
|
||||||
|
if (textBytes) {
|
||||||
|
textBytes.forEach((byte: number, index: number) => {
|
||||||
|
buffer.writeUInt8(byte, index + offset);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
event.buffer = buffer;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createSetScreenPowerModeCommand(mode: boolean): CommandControlMessage {
|
||||||
|
const event = new CommandControlMessage(ControlMessage.TYPE_SET_SCREEN_POWER_MODE);
|
||||||
|
let offset = 0;
|
||||||
|
const buffer = Buffer.alloc(1 + 1);
|
||||||
|
offset = buffer.writeInt8(event.type, offset);
|
||||||
|
buffer.writeUInt8(mode ? 1 : 0, offset);
|
||||||
|
event.buffer = buffer;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createPushFileCommand(params: FilePushParams): CommandControlMessage {
|
||||||
|
const { id, fileName, fileSize, chunk, state } = params;
|
||||||
|
|
||||||
|
if (state === FilePushState.START) {
|
||||||
|
return this.createPushFileStartCommand(id, fileName as string, fileSize as number);
|
||||||
|
}
|
||||||
|
if (state === FilePushState.APPEND) {
|
||||||
|
if (!chunk) {
|
||||||
|
throw TypeError('Invalid type');
|
||||||
|
}
|
||||||
|
return this.createPushFileChunkCommand(id, chunk);
|
||||||
|
}
|
||||||
|
if (state === FilePushState.CANCEL || state === FilePushState.FINISH || state === FilePushState.NEW) {
|
||||||
|
return this.createPushFileOtherCommand(id, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TypeError(`Unsupported state: "${state}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createPushFileStartCommand(id: number, fileName: string, fileSize: number): CommandControlMessage {
|
||||||
|
const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE);
|
||||||
|
const text = Util.stringToUtf8ByteArray(fileName);
|
||||||
|
const typeField = 1;
|
||||||
|
const idField = 2;
|
||||||
|
const stateField = 1;
|
||||||
|
const sizeField = 4;
|
||||||
|
const textLengthField = 2;
|
||||||
|
const textLength = text.length;
|
||||||
|
let offset = CommandControlMessage.PAYLOAD_LENGTH;
|
||||||
|
|
||||||
|
const buffer = Buffer.alloc(
|
||||||
|
offset + typeField + idField + stateField + sizeField + textLengthField + textLength,
|
||||||
|
);
|
||||||
|
buffer.writeUInt8(event.type, offset);
|
||||||
|
offset += typeField;
|
||||||
|
buffer.writeInt16BE(id, offset);
|
||||||
|
offset += idField;
|
||||||
|
buffer.writeInt8(FilePushState.START, offset);
|
||||||
|
offset += stateField;
|
||||||
|
buffer.writeUInt32BE(fileSize, offset);
|
||||||
|
offset += sizeField;
|
||||||
|
buffer.writeUInt16BE(textLength, offset);
|
||||||
|
offset += textLengthField;
|
||||||
|
text.forEach((byte, index) => {
|
||||||
|
buffer.writeUInt8(byte, index + offset);
|
||||||
|
});
|
||||||
|
event.buffer = buffer;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createPushFileChunkCommand(id: number, chunk: Uint8Array): CommandControlMessage {
|
||||||
|
const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE);
|
||||||
|
const typeField = 1;
|
||||||
|
const idField = 2;
|
||||||
|
const stateField = 1;
|
||||||
|
const chunkLengthField = 4;
|
||||||
|
const chunkLength = chunk.byteLength;
|
||||||
|
let offset = CommandControlMessage.PAYLOAD_LENGTH;
|
||||||
|
|
||||||
|
const buffer = Buffer.alloc(offset + typeField + idField + stateField + chunkLengthField + chunkLength);
|
||||||
|
buffer.writeUInt8(event.type, offset);
|
||||||
|
offset += typeField;
|
||||||
|
buffer.writeInt16BE(id, offset);
|
||||||
|
offset += idField;
|
||||||
|
buffer.writeInt8(FilePushState.APPEND, offset);
|
||||||
|
offset += stateField;
|
||||||
|
buffer.writeUInt32BE(chunkLength, offset);
|
||||||
|
offset += chunkLengthField;
|
||||||
|
Array.from(chunk).forEach((byte, index) => {
|
||||||
|
buffer.writeUInt8(byte, index + offset);
|
||||||
|
});
|
||||||
|
event.buffer = buffer;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createPushFileOtherCommand(id: number, state: FilePushState): CommandControlMessage {
|
||||||
|
const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE);
|
||||||
|
const typeField = 1;
|
||||||
|
const idField = 2;
|
||||||
|
const stateField = 1;
|
||||||
|
let offset = CommandControlMessage.PAYLOAD_LENGTH;
|
||||||
|
const buffer = Buffer.alloc(offset + typeField + idField + stateField);
|
||||||
|
buffer.writeUInt8(event.type, offset);
|
||||||
|
offset += typeField;
|
||||||
|
buffer.writeInt16BE(id, offset);
|
||||||
|
offset += idField;
|
||||||
|
buffer.writeInt8(state, offset);
|
||||||
|
event.buffer = buffer;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static pushFileCommandFromBuffer(buffer: Buffer): {
|
||||||
|
id: number;
|
||||||
|
state: FilePushState;
|
||||||
|
chunk?: Buffer;
|
||||||
|
fileSize?: number;
|
||||||
|
fileName?: string;
|
||||||
|
} {
|
||||||
|
let offset = 0;
|
||||||
|
const type = buffer.readUInt8(offset);
|
||||||
|
offset += 1;
|
||||||
|
if (type !== CommandControlMessage.TYPE_PUSH_FILE) {
|
||||||
|
throw TypeError(`Incorrect type: "${type}"`);
|
||||||
|
}
|
||||||
|
const id = buffer.readInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const state = buffer.readInt8(offset);
|
||||||
|
offset += 1;
|
||||||
|
let chunk: Buffer | undefined;
|
||||||
|
let fileSize: number | undefined;
|
||||||
|
let fileName: string | undefined;
|
||||||
|
if (state === FilePushState.APPEND) {
|
||||||
|
const chunkLength = buffer.readUInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
chunk = buffer.slice(offset, offset + chunkLength);
|
||||||
|
} else if (state === FilePushState.START) {
|
||||||
|
fileSize = buffer.readUInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
const textLength = buffer.readUInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
fileName = Util.utf8ByteArrayToString(buffer.slice(offset, offset + textLength));
|
||||||
|
}
|
||||||
|
return { id, state, chunk, fileName, fileSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buffer?: Buffer;
|
||||||
|
|
||||||
|
constructor(readonly type: number) {
|
||||||
|
super(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
public toBuffer(): Buffer {
|
||||||
|
console.log("this.buffer", this.buffer)
|
||||||
|
if (!this.buffer) {
|
||||||
|
console.log('CommandControlMessage.PAYLOAD_LENGTH', CommandControlMessage.PAYLOAD_LENGTH)
|
||||||
|
const buffer = Buffer.alloc(CommandControlMessage.PAYLOAD_LENGTH + 1);
|
||||||
|
buffer.writeUInt8(this.type, 0);
|
||||||
|
this.buffer = buffer;
|
||||||
|
}
|
||||||
|
console.log("转换comm", this.buffer)
|
||||||
|
|
||||||
|
return this.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
const buffer = this.buffer ? `, buffer=[${this.buffer.join(',')}]` : '';
|
||||||
|
return `CommandControlMessage{action=${this.type}${buffer}}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/app/controlMessage/ControlMessage.ts
Normal file
36
src/app/controlMessage/ControlMessage.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface ControlMessageInterface {
|
||||||
|
type: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ControlMessage {
|
||||||
|
public static TYPE_KEYCODE = 0;
|
||||||
|
public static TYPE_TEXT = 1;
|
||||||
|
public static TYPE_TOUCH = 2;
|
||||||
|
public static TYPE_SCROLL = 3;
|
||||||
|
public static TYPE_BACK_OR_SCREEN_ON = 4;
|
||||||
|
public static TYPE_EXPAND_NOTIFICATION_PANEL = 5;
|
||||||
|
public static TYPE_EXPAND_SETTINGS_PANEL = 6;
|
||||||
|
public static TYPE_COLLAPSE_PANELS = 7;
|
||||||
|
public static TYPE_GET_CLIPBOARD = 8;
|
||||||
|
public static TYPE_SET_CLIPBOARD = 9;
|
||||||
|
public static TYPE_SET_SCREEN_POWER_MODE = 10;
|
||||||
|
public static TYPE_ROTATE_DEVICE = 11;
|
||||||
|
public static TYPE_CHANGE_STREAM_PARAMETERS = 101;
|
||||||
|
public static TYPE_PUSH_FILE = 102;
|
||||||
|
|
||||||
|
constructor(readonly type: number) {}
|
||||||
|
|
||||||
|
public toBuffer(): Buffer {
|
||||||
|
throw Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return 'ControlMessage';
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): ControlMessageInterface {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/controlMessage/KeyCodeControlMessage.ts
Normal file
53
src/app/controlMessage/KeyCodeControlMessage.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Buffer } from 'buffer';
|
||||||
|
import { ControlMessage, ControlMessageInterface } from './ControlMessage';
|
||||||
|
|
||||||
|
export interface KeyCodeControlMessageInterface extends ControlMessageInterface {
|
||||||
|
action: number;
|
||||||
|
keycode: number;
|
||||||
|
repeat: number;
|
||||||
|
metaState: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyCodeControlMessage extends ControlMessage {
|
||||||
|
public static PAYLOAD_LENGTH = 13;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly action: number,
|
||||||
|
readonly keycode: number,
|
||||||
|
readonly repeat: number,
|
||||||
|
readonly metaState: number,
|
||||||
|
) {
|
||||||
|
super(ControlMessage.TYPE_KEYCODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
public toBuffer(): Buffer {
|
||||||
|
const buffer = Buffer.alloc(KeyCodeControlMessage.PAYLOAD_LENGTH + 1);
|
||||||
|
let offset = 0;
|
||||||
|
offset = buffer.writeInt8(this.type, offset);
|
||||||
|
offset = buffer.writeInt8(this.action, offset);
|
||||||
|
offset = buffer.writeInt32BE(this.keycode, offset);
|
||||||
|
offset = buffer.writeInt32BE(this.repeat, offset);
|
||||||
|
buffer.writeInt32BE(this.metaState, offset);
|
||||||
|
console.log("转换按钮", buffer)
|
||||||
|
console.log("鼠标按下的key", this.keycode)
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return `KeyCodeControlMessage{action=${this.action}, keycode=${this.keycode}, metaState=${this.metaState}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): KeyCodeControlMessageInterface {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
action: this.action,
|
||||||
|
keycode: this.keycode,
|
||||||
|
metaState: this.metaState,
|
||||||
|
repeat: this.repeat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/controlMessage/ScrollControlMessage.ts
Normal file
47
src/app/controlMessage/ScrollControlMessage.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { ControlMessage, ControlMessageInterface } from './ControlMessage';
|
||||||
|
import Position, { PositionInterface } from '../Position';
|
||||||
|
|
||||||
|
export interface ScrollControlMessageInterface extends ControlMessageInterface {
|
||||||
|
position: PositionInterface;
|
||||||
|
hScroll: number;
|
||||||
|
vScroll: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScrollControlMessage extends ControlMessage {
|
||||||
|
public static PAYLOAD_LENGTH = 20;
|
||||||
|
|
||||||
|
constructor(readonly position: Position, readonly hScroll: number, readonly vScroll: number) {
|
||||||
|
super(ControlMessage.TYPE_SCROLL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
public toBuffer(): Buffer {
|
||||||
|
const buffer = Buffer.alloc(ScrollControlMessage.PAYLOAD_LENGTH + 1);
|
||||||
|
let offset = 0;
|
||||||
|
offset = buffer.writeUInt8(this.type, offset);
|
||||||
|
offset = buffer.writeUInt32BE(this.position.point.x, offset);
|
||||||
|
offset = buffer.writeUInt32BE(this.position.point.y, offset);
|
||||||
|
offset = buffer.writeUInt16BE(this.position.screenSize.width, offset);
|
||||||
|
offset = buffer.writeUInt16BE(this.position.screenSize.height, offset);
|
||||||
|
offset = buffer.writeInt32BE(this.hScroll, offset);
|
||||||
|
buffer.writeInt32BE(this.vScroll, offset);
|
||||||
|
|
||||||
|
console.log("转换text", buffer)
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return `ScrollControlMessage{hScroll=${this.hScroll}, vScroll=${this.vScroll}, position=${this.position}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): ScrollControlMessageInterface {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
position: this.position.toJSON(),
|
||||||
|
hScroll: this.hScroll,
|
||||||
|
vScroll: this.vScroll,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/controlMessage/TextControlMessage.ts
Normal file
43
src/app/controlMessage/TextControlMessage.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Buffer } from 'buffer';
|
||||||
|
import { ControlMessage, ControlMessageInterface } from './ControlMessage';
|
||||||
|
|
||||||
|
export interface TextControlMessageInterface extends ControlMessageInterface {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextControlMessage extends ControlMessage {
|
||||||
|
private static TEXT_SIZE_FIELD_LENGTH = 4;
|
||||||
|
constructor(readonly text: string) {
|
||||||
|
super(ControlMessage.TYPE_TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getText(): string {
|
||||||
|
return this.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
public toBuffer(): Buffer {
|
||||||
|
const length = this.text.length;
|
||||||
|
const buffer = Buffer.alloc(length + 1 + TextControlMessage.TEXT_SIZE_FIELD_LENGTH);
|
||||||
|
let offset = 0;
|
||||||
|
offset = buffer.writeUInt8(this.type, offset);
|
||||||
|
offset = buffer.writeUInt32BE(length, offset);
|
||||||
|
buffer.write(this.text, offset);
|
||||||
|
console.log("转换text", buffer)
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return `TextControlMessage{text=${this.text}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): TextControlMessageInterface {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
text: this.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/app/controlMessage/TouchControlMessage.ts
Normal file
77
src/app/controlMessage/TouchControlMessage.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { ControlMessage, ControlMessageInterface } from './ControlMessage';
|
||||||
|
import Position, { PositionInterface } from '../Position';
|
||||||
|
|
||||||
|
export interface TouchControlMessageInterface extends ControlMessageInterface {
|
||||||
|
type: number;
|
||||||
|
action: number;
|
||||||
|
pointerId: number;
|
||||||
|
position: PositionInterface;
|
||||||
|
pressure: number;
|
||||||
|
buttons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TouchControlMessage extends ControlMessage {
|
||||||
|
public static PAYLOAD_LENGTH = 28;
|
||||||
|
/**
|
||||||
|
* - For a touch screen or touch pad, reports the approximate pressure
|
||||||
|
* applied to the surface by a finger or other tool. The value is
|
||||||
|
* normalized to a range from 0 (no pressure at all) to 1 (normal pressure),
|
||||||
|
* although values higher than 1 may be generated depending on the
|
||||||
|
* calibration of the input device.
|
||||||
|
* - For a trackball, the value is set to 1 if the trackball button is pressed
|
||||||
|
* or 0 otherwise.
|
||||||
|
* - For a mouse, the value is set to 1 if the primary mouse button is pressed
|
||||||
|
* or 0 otherwise.
|
||||||
|
*
|
||||||
|
* - scrcpy server expects signed short (2 bytes) for a pressure value
|
||||||
|
* - in browser TouchEvent has `force` property (values in 0..1 range), we
|
||||||
|
* use it as "pressure" for scrcpy
|
||||||
|
*/
|
||||||
|
public static readonly MAX_PRESSURE_VALUE = 0xffff;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly action: number,
|
||||||
|
readonly pointerId: number,
|
||||||
|
readonly position: Position,
|
||||||
|
readonly pressure: number,
|
||||||
|
readonly buttons: number,
|
||||||
|
) {
|
||||||
|
super(ControlMessage.TYPE_TOUCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
public toBuffer(): Buffer {
|
||||||
|
const buffer: Buffer = Buffer.alloc(TouchControlMessage.PAYLOAD_LENGTH + 1);
|
||||||
|
let offset = 0;
|
||||||
|
offset = buffer.writeUInt8(this.type, offset);
|
||||||
|
offset = buffer.writeUInt8(this.action, offset);
|
||||||
|
offset = buffer.writeUInt32BE(0, offset); // pointerId is `long` (8 bytes) on java side
|
||||||
|
offset = buffer.writeUInt32BE(this.pointerId, offset);
|
||||||
|
offset = buffer.writeUInt32BE(this.position.point.x, offset);
|
||||||
|
offset = buffer.writeUInt32BE(this.position.point.y, offset);
|
||||||
|
offset = buffer.writeUInt16BE(this.position.screenSize.width, offset);
|
||||||
|
offset = buffer.writeUInt16BE(this.position.screenSize.height, offset);
|
||||||
|
offset = buffer.writeUInt16BE(this.pressure * TouchControlMessage.MAX_PRESSURE_VALUE, offset);
|
||||||
|
buffer.writeUInt32BE(this.buttons, offset);
|
||||||
|
// console.log("转换屏幕点击滑动事件", buffer)
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return `TouchControlMessage{action=${this.action}, pointerId=${this.pointerId}, position=${this.position}, pressure=${this.pressure}, buttons=${this.buttons}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): TouchControlMessageInterface {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
action: this.action,
|
||||||
|
pointerId: this.pointerId,
|
||||||
|
position: this.position.toJSON(),
|
||||||
|
pressure: this.pressure,
|
||||||
|
buttons: this.buttons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/googDevice/DeviceMessage.ts
Normal file
54
src/app/googDevice/DeviceMessage.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Util from '../Util';
|
||||||
|
|
||||||
|
export default class DeviceMessage {
|
||||||
|
public static TYPE_CLIPBOARD = 0;
|
||||||
|
public static TYPE_PUSH_RESPONSE = 101;
|
||||||
|
|
||||||
|
public static readonly MAGIC_BYTES_MESSAGE = Util.stringToUtf8ByteArray('scrcpy_message');
|
||||||
|
|
||||||
|
constructor(public readonly type: number, protected readonly buffer: Buffer) { }
|
||||||
|
|
||||||
|
public static fromBuffer(data: ArrayBuffer): DeviceMessage {
|
||||||
|
const magicSize = this.MAGIC_BYTES_MESSAGE.length;
|
||||||
|
const buffer = Buffer.from(data, magicSize, data.byteLength - magicSize);
|
||||||
|
const type = buffer.readUInt8(0);
|
||||||
|
return new DeviceMessage(type, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getText(): string {
|
||||||
|
if (this.type !== DeviceMessage.TYPE_CLIPBOARD) {
|
||||||
|
throw TypeError(`Wrong message type: ${this.type}`);
|
||||||
|
}
|
||||||
|
if (!this.buffer) {
|
||||||
|
throw Error('Empty buffer');
|
||||||
|
}
|
||||||
|
let offset = 1;
|
||||||
|
const length = this.buffer.readInt32BE(offset);
|
||||||
|
offset += 4;
|
||||||
|
const textBytes = this.buffer.slice(offset, offset + length);
|
||||||
|
return Util.utf8ByteArrayToString(textBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPushStats(): { id: number; code: number } {
|
||||||
|
if (this.type !== DeviceMessage.TYPE_PUSH_RESPONSE) {
|
||||||
|
throw TypeError(`Wrong message type: ${this.type}`);
|
||||||
|
}
|
||||||
|
if (!this.buffer) {
|
||||||
|
throw Error('Empty buffer');
|
||||||
|
}
|
||||||
|
console.log("从设备获取到的信息", this.buffer)
|
||||||
|
const id = this.buffer.readInt16BE(1);
|
||||||
|
const code = this.buffer.readInt8(3);
|
||||||
|
return { id, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
let desc: string;
|
||||||
|
if (this.type === DeviceMessage.TYPE_CLIPBOARD && this.buffer) {
|
||||||
|
desc = `, text=[${this.getText()}]`;
|
||||||
|
} else {
|
||||||
|
desc = this.buffer ? `, buffer=[${this.buffer.join(',')}]` : '';
|
||||||
|
}
|
||||||
|
return `DeviceMessage{type=${this.type}${desc}}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/app/googDevice/DragAndDropHandler.ts
Normal file
102
src/app/googDevice/DragAndDropHandler.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export interface DragEventListener {
|
||||||
|
onDragEnter: () => boolean;
|
||||||
|
onDragLeave: () => boolean;
|
||||||
|
onFilesDrop: (files: File[]) => boolean;
|
||||||
|
getElement: () => HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DragAndDropHandler {
|
||||||
|
private static readonly listeners: Set<DragEventListener> = new Set();
|
||||||
|
private static dropHandler = (ev: DragEvent): boolean => {
|
||||||
|
if (!ev.dataTransfer) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: File[] = [];
|
||||||
|
if (ev.dataTransfer.items) {
|
||||||
|
for (let i = 0; i < ev.dataTransfer.items.length; i++) {
|
||||||
|
if (ev.dataTransfer.items[i].kind === 'file') {
|
||||||
|
const file = ev.dataTransfer.items[i].getAsFile();
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < ev.dataTransfer.files.length; i++) {
|
||||||
|
files.push(ev.dataTransfer.files[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let handled = false;
|
||||||
|
DragAndDropHandler.listeners.forEach((listener) => {
|
||||||
|
const element = listener.getElement();
|
||||||
|
if (element === ev.currentTarget) {
|
||||||
|
handled = handled || listener.onFilesDrop(files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (handled) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
private static dragOverHandler = (ev: DragEvent): void => {
|
||||||
|
ev.preventDefault();
|
||||||
|
};
|
||||||
|
private static dragLeaveHandler = (ev: DragEvent): boolean => {
|
||||||
|
let handled = false;
|
||||||
|
DragAndDropHandler.listeners.forEach((listener) => {
|
||||||
|
const element = listener.getElement();
|
||||||
|
if (element === ev.currentTarget) {
|
||||||
|
handled = handled || listener.onDragLeave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (handled) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
private static dragEnterHandler = (ev: DragEvent): boolean => {
|
||||||
|
let handled = false;
|
||||||
|
DragAndDropHandler.listeners.forEach((listener) => {
|
||||||
|
const element = listener.getElement();
|
||||||
|
if (element === ev.currentTarget) {
|
||||||
|
handled = handled || listener.onDragEnter();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (handled) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
private static attachListeners(element: HTMLElement): void {
|
||||||
|
element.addEventListener('drop', this.dropHandler);
|
||||||
|
element.addEventListener('dragover', this.dragOverHandler);
|
||||||
|
element.addEventListener('dragleave', this.dragLeaveHandler);
|
||||||
|
element.addEventListener('dragenter', this.dragEnterHandler);
|
||||||
|
}
|
||||||
|
private static detachListeners(element: HTMLElement): void {
|
||||||
|
element.removeEventListener('drop', this.dropHandler);
|
||||||
|
element.removeEventListener('dragover', this.dragOverHandler);
|
||||||
|
element.removeEventListener('dragleave', this.dragLeaveHandler);
|
||||||
|
element.removeEventListener('dragenter', this.dragEnterHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static addEventListener(listener: DragEventListener): void {
|
||||||
|
if (this.listeners.has(listener)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.attachListeners(listener.getElement());
|
||||||
|
this.listeners.add(listener);
|
||||||
|
}
|
||||||
|
public static removeEventListener(listener: DragEventListener): void {
|
||||||
|
if (!this.listeners.has(listener)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.detachListeners(listener.getElement());
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/app/googDevice/DragAndPushLogger.ts
Normal file
129
src/app/googDevice/DragAndPushLogger.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import FilePushHandler, { DragAndPushListener, PushUpdateParams } from './filePush/FilePushHandler';
|
||||||
|
|
||||||
|
const TAG = '[DragAndPushLogger]';
|
||||||
|
|
||||||
|
export default class DragAndPushLogger implements DragAndPushListener {
|
||||||
|
private static readonly X: number = 20;
|
||||||
|
private static readonly Y: number = 40;
|
||||||
|
private static readonly HEIGHT = 12;
|
||||||
|
private static readonly LOG_BACKGROUND: string = 'rgba(0,0,0, 0.5)';
|
||||||
|
private static readonly DEBUG_COLOR: string = 'hsl(136, 85%,50%)';
|
||||||
|
private static readonly ERROR_COLOR: string = 'hsl(336,85%,50%)';
|
||||||
|
|
||||||
|
private readonly ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
private timeoutMap: Map<number, number> = new Map();
|
||||||
|
private dirtyMap: Map<number, number> = new Map();
|
||||||
|
private pushLineMap: Map<string, number> = new Map();
|
||||||
|
private linePushMap: Map<number, string> = new Map();
|
||||||
|
private dirtyLines: boolean[] = [];
|
||||||
|
constructor(element: HTMLElement) {
|
||||||
|
if (element instanceof HTMLCanvasElement) {
|
||||||
|
const canvas = element as HTMLCanvasElement;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanDirtyLine = (line: number): void => {
|
||||||
|
if (!this.ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { X, Y, HEIGHT } = DragAndPushLogger;
|
||||||
|
const x = X;
|
||||||
|
const y = Y + HEIGHT * line * 2;
|
||||||
|
const dirty = this.dirtyMap.get(line);
|
||||||
|
if (dirty) {
|
||||||
|
const p = DragAndPushLogger.HEIGHT / 2;
|
||||||
|
const d = p * 2;
|
||||||
|
this.ctx.clearRect(x - p, y - HEIGHT - p, dirty + d, HEIGHT + d);
|
||||||
|
}
|
||||||
|
this.dirtyLines[line] = false;
|
||||||
|
};
|
||||||
|
private logText(text: string, line: number, scheduleCleanup = false, error = false): void {
|
||||||
|
if (!this.ctx) {
|
||||||
|
error ? console.error(TAG, text) : console.log(TAG, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
console.error(TAG, text);
|
||||||
|
}
|
||||||
|
this.cleanDirtyLine(line);
|
||||||
|
|
||||||
|
const { X, Y, HEIGHT } = DragAndPushLogger;
|
||||||
|
const x = X;
|
||||||
|
const y = Y + HEIGHT * line * 2;
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.font = `${HEIGHT}px monospace`;
|
||||||
|
const textMetrics = this.ctx.measureText(text);
|
||||||
|
const width = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
|
||||||
|
this.dirtyMap.set(line, width);
|
||||||
|
this.ctx.fillStyle = DragAndPushLogger.LOG_BACKGROUND;
|
||||||
|
const p = DragAndPushLogger.HEIGHT / 2 - 1;
|
||||||
|
const d = p * 2;
|
||||||
|
this.ctx.fillRect(x - p, y - HEIGHT - p, width + d, HEIGHT + d);
|
||||||
|
this.ctx.fillStyle = error ? DragAndPushLogger.ERROR_COLOR : DragAndPushLogger.DEBUG_COLOR;
|
||||||
|
this.ctx.fillText(text, x, y);
|
||||||
|
this.ctx.restore();
|
||||||
|
if (scheduleCleanup) {
|
||||||
|
this.dirtyLines[line] = true;
|
||||||
|
let timeout = this.timeoutMap.get(line);
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = window.setTimeout(() => {
|
||||||
|
this.cleanDirtyLine(line);
|
||||||
|
const key = this.linePushMap.get(line);
|
||||||
|
if (typeof key === 'string') {
|
||||||
|
this.linePushMap.delete(line);
|
||||||
|
this.pushLineMap.delete(key);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
this.timeoutMap.set(line, timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragEnter(): boolean {
|
||||||
|
this.logText('Drop APK files here', 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragLeave(): boolean {
|
||||||
|
this.cleanDirtyLine(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDrop(): boolean {
|
||||||
|
this.cleanDirtyLine(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onError(error: Error | string): void {
|
||||||
|
const text = typeof error === 'string' ? error : error.message;
|
||||||
|
this.logText(text, 1, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilePushUpdate(data: PushUpdateParams): void {
|
||||||
|
const { pushId, message, fileName, error } = data;
|
||||||
|
const key = `${pushId}/${fileName}`;
|
||||||
|
const firstKey = `${FilePushHandler.REQUEST_NEW_PUSH_ID}/${fileName}`;
|
||||||
|
let line: number | undefined = this.pushLineMap.get(key);
|
||||||
|
let update = false;
|
||||||
|
if (typeof line === 'undefined' && key !== firstKey) {
|
||||||
|
line = this.pushLineMap.get(firstKey);
|
||||||
|
if (typeof line !== 'undefined') {
|
||||||
|
this.pushLineMap.delete(firstKey);
|
||||||
|
update = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof line === 'undefined') {
|
||||||
|
line = 2;
|
||||||
|
while (this.dirtyLines[line]) {
|
||||||
|
line++;
|
||||||
|
}
|
||||||
|
update = true;
|
||||||
|
}
|
||||||
|
if (update) {
|
||||||
|
this.pushLineMap.set(key, line);
|
||||||
|
this.linePushMap.set(line, key);
|
||||||
|
}
|
||||||
|
this.logText(`Upload "${fileName}": ${message}`, line, true, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/googDevice/Entry.ts
Normal file
11
src/app/googDevice/Entry.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Stats } from './Stats';
|
||||||
|
|
||||||
|
export class Entry extends Stats {
|
||||||
|
constructor(public name: string, mode: number, size: number, mtime: number) {
|
||||||
|
super(mode, size, mtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/app/googDevice/KeyInputHandler.ts
Normal file
79
src/app/googDevice/KeyInputHandler.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { KeyCodeControlMessage } from '../controlMessage/KeyCodeControlMessage';
|
||||||
|
import KeyEvent from './android/KeyEvent';
|
||||||
|
import { KeyToCodeMap } from './KeyToCodeMap';
|
||||||
|
|
||||||
|
export interface KeyEventListener {
|
||||||
|
onKeyEvent: (event: KeyCodeControlMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyInputHandler {
|
||||||
|
private static readonly repeatCounter: Map<number, number> = new Map();
|
||||||
|
private static readonly listeners: Set<KeyEventListener> = new Set();
|
||||||
|
private static handler = (event: Event): void => {
|
||||||
|
const keyboardEvent = event as KeyboardEvent;
|
||||||
|
const keyCode = KeyToCodeMap.get(keyboardEvent.code);
|
||||||
|
if (!keyCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let action: typeof KeyEvent.ACTION_DOWN | typeof KeyEvent.ACTION_DOWN;
|
||||||
|
let repeatCount = 0;
|
||||||
|
console.log('键盘按下', keyCode)
|
||||||
|
if (keyboardEvent.type === 'keydown') {
|
||||||
|
action = KeyEvent.ACTION_DOWN;
|
||||||
|
if (keyboardEvent.repeat) {
|
||||||
|
let count = KeyInputHandler.repeatCounter.get(keyCode);
|
||||||
|
if (typeof count !== 'number') {
|
||||||
|
count = 1;
|
||||||
|
} else {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
repeatCount = count;
|
||||||
|
KeyInputHandler.repeatCounter.set(keyCode, count);
|
||||||
|
}
|
||||||
|
} else if (keyboardEvent.type === 'keyup') {
|
||||||
|
action = KeyEvent.ACTION_UP;
|
||||||
|
KeyInputHandler.repeatCounter.delete(keyCode);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const metaState =
|
||||||
|
(keyboardEvent.getModifierState('Alt') ? KeyEvent.META_ALT_ON : 0) |
|
||||||
|
(keyboardEvent.getModifierState('Shift') ? KeyEvent.META_SHIFT_ON : 0) |
|
||||||
|
(keyboardEvent.getModifierState('Control') ? KeyEvent.META_CTRL_ON : 0) |
|
||||||
|
(keyboardEvent.getModifierState('Meta') ? KeyEvent.META_META_ON : 0) |
|
||||||
|
(keyboardEvent.getModifierState('CapsLock') ? KeyEvent.META_CAPS_LOCK_ON : 0) |
|
||||||
|
(keyboardEvent.getModifierState('ScrollLock') ? KeyEvent.META_SCROLL_LOCK_ON : 0) |
|
||||||
|
(keyboardEvent.getModifierState('NumLock') ? KeyEvent.META_NUM_LOCK_ON : 0);
|
||||||
|
|
||||||
|
const controlMessage: KeyCodeControlMessage = new KeyCodeControlMessage(
|
||||||
|
action,
|
||||||
|
keyCode,
|
||||||
|
repeatCount,
|
||||||
|
metaState,
|
||||||
|
);
|
||||||
|
KeyInputHandler.listeners.forEach((listener) => {
|
||||||
|
listener.onKeyEvent(controlMessage);
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
private static attachListeners(): void {
|
||||||
|
document.body.addEventListener('keydown', this.handler);
|
||||||
|
document.body.addEventListener('keyup', this.handler);
|
||||||
|
}
|
||||||
|
private static detachListeners(): void {
|
||||||
|
document.body.removeEventListener('keydown', this.handler);
|
||||||
|
document.body.removeEventListener('keyup', this.handler);
|
||||||
|
}
|
||||||
|
public static addEventListener(listener: KeyEventListener): void {
|
||||||
|
if (!this.listeners.size) {
|
||||||
|
this.attachListeners();
|
||||||
|
}
|
||||||
|
this.listeners.add(listener);
|
||||||
|
}
|
||||||
|
public static removeEventListener(listener: KeyEventListener): void {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
if (!this.listeners.size) {
|
||||||
|
this.detachListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/app/googDevice/KeyToCodeMap.ts
Normal file
118
src/app/googDevice/KeyToCodeMap.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import KeyEvent from './android/KeyEvent';
|
||||||
|
import UIEventsCode from '../UIEventsCode';
|
||||||
|
|
||||||
|
export const KeyToCodeMap = new Map([
|
||||||
|
[UIEventsCode.Backquote, KeyEvent.KEYCODE_GRAVE],
|
||||||
|
[UIEventsCode.Backslash, KeyEvent.KEYCODE_BACKSLASH],
|
||||||
|
[UIEventsCode.BracketLeft, KeyEvent.KEYCODE_LEFT_BRACKET],
|
||||||
|
[UIEventsCode.BracketRight, KeyEvent.KEYCODE_RIGHT_BRACKET],
|
||||||
|
[UIEventsCode.Comma, KeyEvent.KEYCODE_COMMA],
|
||||||
|
[UIEventsCode.Digit0, KeyEvent.KEYCODE_0],
|
||||||
|
[UIEventsCode.Digit1, KeyEvent.KEYCODE_1],
|
||||||
|
[UIEventsCode.Digit2, KeyEvent.KEYCODE_2],
|
||||||
|
[UIEventsCode.Digit3, KeyEvent.KEYCODE_3],
|
||||||
|
[UIEventsCode.Digit4, KeyEvent.KEYCODE_4],
|
||||||
|
[UIEventsCode.Digit5, KeyEvent.KEYCODE_5],
|
||||||
|
[UIEventsCode.Digit6, KeyEvent.KEYCODE_6],
|
||||||
|
[UIEventsCode.Digit7, KeyEvent.KEYCODE_7],
|
||||||
|
[UIEventsCode.Digit8, KeyEvent.KEYCODE_8],
|
||||||
|
[UIEventsCode.Digit9, KeyEvent.KEYCODE_9],
|
||||||
|
[UIEventsCode.Equal, KeyEvent.KEYCODE_EQUALS],
|
||||||
|
[UIEventsCode.IntlRo, KeyEvent.KEYCODE_RO],
|
||||||
|
[UIEventsCode.IntlYen, KeyEvent.KEYCODE_YEN],
|
||||||
|
[UIEventsCode.KeyA, KeyEvent.KEYCODE_A],
|
||||||
|
[UIEventsCode.KeyB, KeyEvent.KEYCODE_B],
|
||||||
|
[UIEventsCode.KeyC, KeyEvent.KEYCODE_C],
|
||||||
|
[UIEventsCode.KeyD, KeyEvent.KEYCODE_D],
|
||||||
|
[UIEventsCode.KeyE, KeyEvent.KEYCODE_E],
|
||||||
|
[UIEventsCode.KeyF, KeyEvent.KEYCODE_F],
|
||||||
|
[UIEventsCode.KeyG, KeyEvent.KEYCODE_G],
|
||||||
|
[UIEventsCode.KeyH, KeyEvent.KEYCODE_H],
|
||||||
|
[UIEventsCode.KeyI, KeyEvent.KEYCODE_I],
|
||||||
|
[UIEventsCode.KeyJ, KeyEvent.KEYCODE_J],
|
||||||
|
[UIEventsCode.KeyK, KeyEvent.KEYCODE_K],
|
||||||
|
[UIEventsCode.KeyL, KeyEvent.KEYCODE_L],
|
||||||
|
[UIEventsCode.KeyM, KeyEvent.KEYCODE_M],
|
||||||
|
[UIEventsCode.KeyN, KeyEvent.KEYCODE_N],
|
||||||
|
[UIEventsCode.KeyO, KeyEvent.KEYCODE_O],
|
||||||
|
[UIEventsCode.KeyP, KeyEvent.KEYCODE_P],
|
||||||
|
[UIEventsCode.KeyQ, KeyEvent.KEYCODE_Q],
|
||||||
|
[UIEventsCode.KeyR, KeyEvent.KEYCODE_R],
|
||||||
|
[UIEventsCode.KeyS, KeyEvent.KEYCODE_S],
|
||||||
|
[UIEventsCode.KeyT, KeyEvent.KEYCODE_T],
|
||||||
|
[UIEventsCode.KeyU, KeyEvent.KEYCODE_U],
|
||||||
|
[UIEventsCode.KeyV, KeyEvent.KEYCODE_V],
|
||||||
|
[UIEventsCode.KeyW, KeyEvent.KEYCODE_W],
|
||||||
|
[UIEventsCode.KeyX, KeyEvent.KEYCODE_X],
|
||||||
|
[UIEventsCode.KeyY, KeyEvent.KEYCODE_Y],
|
||||||
|
[UIEventsCode.KeyZ, KeyEvent.KEYCODE_Z],
|
||||||
|
[UIEventsCode.Minus, KeyEvent.KEYCODE_MINUS],
|
||||||
|
[UIEventsCode.Period, KeyEvent.KEYCODE_PERIOD],
|
||||||
|
[UIEventsCode.Quote, KeyEvent.KEYCODE_APOSTROPHE],
|
||||||
|
[UIEventsCode.Semicolon, KeyEvent.KEYCODE_SEMICOLON],
|
||||||
|
[UIEventsCode.Slash, KeyEvent.KEYCODE_SLASH],
|
||||||
|
[UIEventsCode.KanaMode, KeyEvent.KEYCODE_KANA],
|
||||||
|
[UIEventsCode.Delete, KeyEvent.KEYCODE_FORWARD_DEL],
|
||||||
|
[UIEventsCode.End, KeyEvent.KEYCODE_MOVE_END],
|
||||||
|
[UIEventsCode.Help, KeyEvent.KEYCODE_HELP],
|
||||||
|
[UIEventsCode.Home, KeyEvent.KEYCODE_MOVE_HOME],
|
||||||
|
[UIEventsCode.Insert, KeyEvent.KEYCODE_INSERT],
|
||||||
|
[UIEventsCode.PageDown, KeyEvent.KEYCODE_PAGE_DOWN],
|
||||||
|
[UIEventsCode.PageUp, KeyEvent.KEYCODE_PAGE_UP],
|
||||||
|
[UIEventsCode.AltLeft, KeyEvent.KEYCODE_ALT_LEFT],
|
||||||
|
[UIEventsCode.AltRight, KeyEvent.KEYCODE_ALT_RIGHT],
|
||||||
|
[UIEventsCode.Backspace, KeyEvent.KEYCODE_DEL],
|
||||||
|
[UIEventsCode.CapsLock, KeyEvent.KEYCODE_CAPS_LOCK],
|
||||||
|
[UIEventsCode.ControlLeft, KeyEvent.KEYCODE_CTRL_LEFT],
|
||||||
|
[UIEventsCode.ControlRight, KeyEvent.KEYCODE_CTRL_RIGHT],
|
||||||
|
[UIEventsCode.Enter, KeyEvent.KEYCODE_ENTER],
|
||||||
|
[UIEventsCode.MetaLeft, KeyEvent.KEYCODE_META_LEFT],
|
||||||
|
[UIEventsCode.MetaRight, KeyEvent.KEYCODE_META_RIGHT],
|
||||||
|
[UIEventsCode.ShiftLeft, KeyEvent.KEYCODE_SHIFT_LEFT],
|
||||||
|
[UIEventsCode.ShiftRight, KeyEvent.KEYCODE_SHIFT_RIGHT],
|
||||||
|
[UIEventsCode.Space, KeyEvent.KEYCODE_SPACE],
|
||||||
|
[UIEventsCode.Tab, KeyEvent.KEYCODE_TAB],
|
||||||
|
[UIEventsCode.ArrowLeft, KeyEvent.KEYCODE_DPAD_LEFT],
|
||||||
|
[UIEventsCode.ArrowUp, KeyEvent.KEYCODE_DPAD_UP],
|
||||||
|
[UIEventsCode.ArrowRight, KeyEvent.KEYCODE_DPAD_RIGHT],
|
||||||
|
[UIEventsCode.ArrowDown, KeyEvent.KEYCODE_DPAD_DOWN],
|
||||||
|
|
||||||
|
[UIEventsCode.NumLock, KeyEvent.KEYCODE_NUM_LOCK],
|
||||||
|
[UIEventsCode.Numpad0, KeyEvent.KEYCODE_NUMPAD_0],
|
||||||
|
[UIEventsCode.Numpad1, KeyEvent.KEYCODE_NUMPAD_1],
|
||||||
|
[UIEventsCode.Numpad2, KeyEvent.KEYCODE_NUMPAD_2],
|
||||||
|
[UIEventsCode.Numpad3, KeyEvent.KEYCODE_NUMPAD_3],
|
||||||
|
[UIEventsCode.Numpad4, KeyEvent.KEYCODE_NUMPAD_4],
|
||||||
|
[UIEventsCode.Numpad5, KeyEvent.KEYCODE_NUMPAD_5],
|
||||||
|
[UIEventsCode.Numpad6, KeyEvent.KEYCODE_NUMPAD_6],
|
||||||
|
[UIEventsCode.Numpad7, KeyEvent.KEYCODE_NUMPAD_7],
|
||||||
|
[UIEventsCode.Numpad8, KeyEvent.KEYCODE_NUMPAD_8],
|
||||||
|
[UIEventsCode.Numpad9, KeyEvent.KEYCODE_NUMPAD_9],
|
||||||
|
[UIEventsCode.NumpadAdd, KeyEvent.KEYCODE_NUMPAD_ADD],
|
||||||
|
[UIEventsCode.NumpadComma, KeyEvent.KEYCODE_NUMPAD_COMMA],
|
||||||
|
[UIEventsCode.NumpadDecimal, KeyEvent.KEYCODE_NUMPAD_DOT],
|
||||||
|
[UIEventsCode.NumpadDivide, KeyEvent.KEYCODE_NUMPAD_DIVIDE],
|
||||||
|
[UIEventsCode.NumpadEnter, KeyEvent.KEYCODE_NUMPAD_ENTER],
|
||||||
|
[UIEventsCode.NumpadEqual, KeyEvent.KEYCODE_NUMPAD_EQUALS],
|
||||||
|
[UIEventsCode.NumpadMultiply, KeyEvent.KEYCODE_NUMPAD_MULTIPLY],
|
||||||
|
[UIEventsCode.NumpadParenLeft, KeyEvent.KEYCODE_NUMPAD_LEFT_PAREN],
|
||||||
|
[UIEventsCode.NumpadParenRight, KeyEvent.KEYCODE_NUMPAD_RIGHT_PAREN],
|
||||||
|
[UIEventsCode.NumpadSubtract, KeyEvent.KEYCODE_NUMPAD_SUBTRACT],
|
||||||
|
|
||||||
|
[UIEventsCode.Escape, KeyEvent.KEYCODE_ESCAPE],
|
||||||
|
[UIEventsCode.F1, KeyEvent.KEYCODE_F1],
|
||||||
|
[UIEventsCode.F2, KeyEvent.KEYCODE_F2],
|
||||||
|
[UIEventsCode.F3, KeyEvent.KEYCODE_F3],
|
||||||
|
[UIEventsCode.F4, KeyEvent.KEYCODE_F4],
|
||||||
|
[UIEventsCode.F5, KeyEvent.KEYCODE_F5],
|
||||||
|
[UIEventsCode.F6, KeyEvent.KEYCODE_F6],
|
||||||
|
[UIEventsCode.F7, KeyEvent.KEYCODE_F7],
|
||||||
|
[UIEventsCode.F8, KeyEvent.KEYCODE_F8],
|
||||||
|
[UIEventsCode.F9, KeyEvent.KEYCODE_F9],
|
||||||
|
[UIEventsCode.F10, KeyEvent.KEYCODE_F10],
|
||||||
|
[UIEventsCode.F11, KeyEvent.KEYCODE_F11],
|
||||||
|
[UIEventsCode.F12, KeyEvent.KEYCODE_F12],
|
||||||
|
[UIEventsCode.Fn, KeyEvent.KEYCODE_FUNCTION],
|
||||||
|
[UIEventsCode.PrintScreen, KeyEvent.KEYCODE_SYSRQ],
|
||||||
|
[UIEventsCode.Pause, KeyEvent.KEYCODE_BREAK],
|
||||||
|
]);
|
||||||
67
src/app/googDevice/Stats.ts
Normal file
67
src/app/googDevice/Stats.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
export class Stats {
|
||||||
|
// The following constant were extracted from `man 2 stat` on Ubuntu 12.10.
|
||||||
|
public static S_IFMT = 0o170000; // bit mask for the file type bit fields
|
||||||
|
|
||||||
|
public static S_IFSOCK = 0o140000; // socket
|
||||||
|
|
||||||
|
public static S_IFLNK = 0o120000; // symbolic link
|
||||||
|
|
||||||
|
public static S_IFREG = 0o100000; // regular file
|
||||||
|
|
||||||
|
public static S_IFBLK = 0o060000; // block device
|
||||||
|
|
||||||
|
public static S_IFDIR = 0o040000; // directory
|
||||||
|
|
||||||
|
public static S_IFCHR = 0o020000; // character device
|
||||||
|
|
||||||
|
public static S_IFIFO = 0o010000; // FIFO
|
||||||
|
|
||||||
|
public static S_ISUID = 0o004000; // set UID bit
|
||||||
|
|
||||||
|
public static S_ISGID = 0o002000; // set-group-ID bit (see below)
|
||||||
|
|
||||||
|
public static S_ISVTX = 0o001000; // sticky bit (see below)
|
||||||
|
|
||||||
|
public static S_IRWXU = 0o0700; // mask for file owner permissions
|
||||||
|
|
||||||
|
public static S_IRUSR = 0o0400; // owner has read permission
|
||||||
|
|
||||||
|
public static S_IWUSR = 0o0200; // owner has write permission
|
||||||
|
|
||||||
|
public static S_IXUSR = 0o0100; // owner has execute permission
|
||||||
|
|
||||||
|
public static S_IRWXG = 0o0070; // mask for group permissions
|
||||||
|
|
||||||
|
public static S_IRGRP = 0o0040; // group has read permission
|
||||||
|
|
||||||
|
public readonly mtime: Date;
|
||||||
|
|
||||||
|
constructor(public readonly mode: number, public readonly size: number, mtime: number) {
|
||||||
|
this.mtime = new Date(mtime * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkModeProperty(property: number): boolean {
|
||||||
|
return (this.mode & Stats.S_IFMT) === property;
|
||||||
|
}
|
||||||
|
public isBlockDevice(): boolean {
|
||||||
|
return this.checkModeProperty(Stats.S_IFBLK);
|
||||||
|
}
|
||||||
|
public isCharacterDevice(): boolean {
|
||||||
|
return this.checkModeProperty(Stats.S_IFCHR);
|
||||||
|
}
|
||||||
|
public isDirectory(): boolean {
|
||||||
|
return this.checkModeProperty(Stats.S_IFDIR);
|
||||||
|
}
|
||||||
|
public isFIFO(): boolean {
|
||||||
|
return this.checkModeProperty(Stats.S_IFIFO);
|
||||||
|
}
|
||||||
|
public isisSocket(): boolean {
|
||||||
|
return this.checkModeProperty(Stats.S_IFSOCK);
|
||||||
|
}
|
||||||
|
public isSymbolicLink(): boolean {
|
||||||
|
return this.checkModeProperty(Stats.S_IFLNK);
|
||||||
|
}
|
||||||
|
public isFile(): boolean {
|
||||||
|
return this.checkModeProperty(Stats.S_IFREG);
|
||||||
|
}
|
||||||
|
}
|
||||||
317
src/app/googDevice/android/KeyEvent.ts
Normal file
317
src/app/googDevice/android/KeyEvent.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
export default class KeyEvent {
|
||||||
|
// 定义按键常量
|
||||||
|
public static readonly ACTION_DOWN: number = 0;
|
||||||
|
public static readonly ACTION_UP: number = 1;
|
||||||
|
|
||||||
|
public static readonly KEYCODE_0: number = 7;
|
||||||
|
public static readonly KEYCODE_1: number = 8;
|
||||||
|
public static readonly KEYCODE_11: number = 227;
|
||||||
|
public static readonly KEYCODE_12: number = 228;
|
||||||
|
public static readonly KEYCODE_2: number = 9;
|
||||||
|
public static readonly KEYCODE_3: number = 10;
|
||||||
|
public static readonly KEYCODE_3D_MODE: number = 206;
|
||||||
|
public static readonly KEYCODE_4: number = 11;
|
||||||
|
public static readonly KEYCODE_5: number = 12;
|
||||||
|
public static readonly KEYCODE_6: number = 13;
|
||||||
|
public static readonly KEYCODE_7: number = 14;
|
||||||
|
public static readonly KEYCODE_8: number = 15;
|
||||||
|
public static readonly KEYCODE_9: number = 16;
|
||||||
|
public static readonly KEYCODE_A: number = 29;
|
||||||
|
public static readonly KEYCODE_ALL_APPS: number = 284;
|
||||||
|
public static readonly KEYCODE_ALT_LEFT: number = 57;
|
||||||
|
public static readonly KEYCODE_ALT_RIGHT: number = 58;
|
||||||
|
public static readonly KEYCODE_APOSTROPHE: number = 75;
|
||||||
|
public static readonly KEYCODE_APP_SWITCH: number = 187;
|
||||||
|
public static readonly KEYCODE_ASSIST: number = 219;
|
||||||
|
public static readonly KEYCODE_AT: number = 77;
|
||||||
|
public static readonly KEYCODE_AVR_INPUT: number = 182;
|
||||||
|
public static readonly KEYCODE_AVR_POWER: number = 181;
|
||||||
|
public static readonly KEYCODE_B: number = 30;
|
||||||
|
public static readonly KEYCODE_BACK: number = 4;
|
||||||
|
public static readonly KEYCODE_BACKSLASH: number = 73;
|
||||||
|
public static readonly KEYCODE_BOOKMARK: number = 174;
|
||||||
|
public static readonly KEYCODE_BREAK: number = 121;
|
||||||
|
public static readonly KEYCODE_BRIGHTNESS_DOWN: number = 220;
|
||||||
|
public static readonly KEYCODE_BRIGHTNESS_UP: number = 221;
|
||||||
|
public static readonly KEYCODE_BUTTON_1: number = 188;
|
||||||
|
public static readonly KEYCODE_BUTTON_10: number = 197;
|
||||||
|
public static readonly KEYCODE_BUTTON_11: number = 198;
|
||||||
|
public static readonly KEYCODE_BUTTON_12: number = 199;
|
||||||
|
public static readonly KEYCODE_BUTTON_13: number = 200;
|
||||||
|
public static readonly KEYCODE_BUTTON_14: number = 201;
|
||||||
|
public static readonly KEYCODE_BUTTON_15: number = 202;
|
||||||
|
public static readonly KEYCODE_BUTTON_16: number = 203;
|
||||||
|
public static readonly KEYCODE_BUTTON_2: number = 189;
|
||||||
|
public static readonly KEYCODE_BUTTON_3: number = 190;
|
||||||
|
public static readonly KEYCODE_BUTTON_4: number = 191;
|
||||||
|
public static readonly KEYCODE_BUTTON_5: number = 192;
|
||||||
|
public static readonly KEYCODE_BUTTON_6: number = 193;
|
||||||
|
public static readonly KEYCODE_BUTTON_7: number = 194;
|
||||||
|
public static readonly KEYCODE_BUTTON_8: number = 195;
|
||||||
|
public static readonly KEYCODE_BUTTON_9: number = 196;
|
||||||
|
public static readonly KEYCODE_BUTTON_A: number = 96;
|
||||||
|
public static readonly KEYCODE_BUTTON_B: number = 97;
|
||||||
|
public static readonly KEYCODE_BUTTON_C: number = 98;
|
||||||
|
public static readonly KEYCODE_BUTTON_L1: number = 102;
|
||||||
|
public static readonly KEYCODE_BUTTON_L2: number = 104;
|
||||||
|
public static readonly KEYCODE_BUTTON_MODE: number = 110;
|
||||||
|
public static readonly KEYCODE_BUTTON_R1: number = 103;
|
||||||
|
public static readonly KEYCODE_BUTTON_R2: number = 105;
|
||||||
|
public static readonly KEYCODE_BUTTON_SELECT: number = 109;
|
||||||
|
public static readonly KEYCODE_BUTTON_START: number = 108;
|
||||||
|
public static readonly KEYCODE_BUTTON_THUMBL: number = 106;
|
||||||
|
public static readonly KEYCODE_BUTTON_THUMBR: number = 107;
|
||||||
|
public static readonly KEYCODE_BUTTON_X: number = 99;
|
||||||
|
public static readonly KEYCODE_BUTTON_Y: number = 100;
|
||||||
|
public static readonly KEYCODE_BUTTON_Z: number = 101;
|
||||||
|
public static readonly KEYCODE_C: number = 31;
|
||||||
|
public static readonly KEYCODE_CALCULATOR: number = 210;
|
||||||
|
public static readonly KEYCODE_CALENDAR: number = 208;
|
||||||
|
public static readonly KEYCODE_CALL: number = 5;
|
||||||
|
public static readonly KEYCODE_CAMERA: number = 27;
|
||||||
|
public static readonly KEYCODE_CAPS_LOCK: number = 115;
|
||||||
|
public static readonly KEYCODE_CAPTIONS: number = 175;
|
||||||
|
public static readonly KEYCODE_CHANNEL_DOWN: number = 167;
|
||||||
|
public static readonly KEYCODE_CHANNEL_UP: number = 166;
|
||||||
|
public static readonly KEYCODE_CLEAR: number = 28;
|
||||||
|
public static readonly KEYCODE_COMMA: number = 55;
|
||||||
|
public static readonly KEYCODE_CONTACTS: number = 207;
|
||||||
|
public static readonly KEYCODE_COPY: number = 278;
|
||||||
|
public static readonly KEYCODE_CTRL_LEFT: number = 113;
|
||||||
|
public static readonly KEYCODE_CTRL_RIGHT: number = 114;
|
||||||
|
public static readonly KEYCODE_CUT: number = 277;
|
||||||
|
public static readonly KEYCODE_D: number = 32;
|
||||||
|
public static readonly KEYCODE_DEL: number = 67;
|
||||||
|
public static readonly KEYCODE_DPAD_CENTER: number = 23;
|
||||||
|
public static readonly KEYCODE_DPAD_DOWN: number = 20;
|
||||||
|
public static readonly KEYCODE_DPAD_DOWN_LEFT: number = 269;
|
||||||
|
public static readonly KEYCODE_DPAD_DOWN_RIGHT: number = 271;
|
||||||
|
public static readonly KEYCODE_DPAD_LEFT: number = 21;
|
||||||
|
public static readonly KEYCODE_DPAD_RIGHT: number = 22;
|
||||||
|
public static readonly KEYCODE_DPAD_UP: number = 19;
|
||||||
|
public static readonly KEYCODE_DPAD_UP_LEFT: number = 268;
|
||||||
|
public static readonly KEYCODE_DPAD_UP_RIGHT: number = 270;
|
||||||
|
public static readonly KEYCODE_DVR: number = 173;
|
||||||
|
public static readonly KEYCODE_E: number = 33;
|
||||||
|
public static readonly KEYCODE_EISU: number = 212;
|
||||||
|
public static readonly KEYCODE_ENDCALL: number = 6;
|
||||||
|
public static readonly KEYCODE_ENTER: number = 66;
|
||||||
|
public static readonly KEYCODE_ENVELOPE: number = 65;
|
||||||
|
public static readonly KEYCODE_EQUALS: number = 70;
|
||||||
|
public static readonly KEYCODE_ESCAPE: number = 111;
|
||||||
|
public static readonly KEYCODE_EXPLORER: number = 64;
|
||||||
|
public static readonly KEYCODE_F: number = 34;
|
||||||
|
public static readonly KEYCODE_F1: number = 131;
|
||||||
|
public static readonly KEYCODE_F10: number = 140;
|
||||||
|
public static readonly KEYCODE_F11: number = 141;
|
||||||
|
public static readonly KEYCODE_F12: number = 142;
|
||||||
|
public static readonly KEYCODE_F2: number = 132;
|
||||||
|
public static readonly KEYCODE_F3: number = 133;
|
||||||
|
public static readonly KEYCODE_F4: number = 134;
|
||||||
|
public static readonly KEYCODE_F5: number = 135;
|
||||||
|
public static readonly KEYCODE_F6: number = 136;
|
||||||
|
public static readonly KEYCODE_F7: number = 137;
|
||||||
|
public static readonly KEYCODE_F8: number = 138;
|
||||||
|
public static readonly KEYCODE_F9: number = 139;
|
||||||
|
public static readonly KEYCODE_FOCUS: number = 80;
|
||||||
|
public static readonly KEYCODE_FORWARD: number = 125;
|
||||||
|
public static readonly KEYCODE_FORWARD_DEL: number = 112;
|
||||||
|
public static readonly KEYCODE_FUNCTION: number = 119;
|
||||||
|
public static readonly KEYCODE_G: number = 35;
|
||||||
|
public static readonly KEYCODE_GRAVE: number = 68;
|
||||||
|
public static readonly KEYCODE_GUIDE: number = 172;
|
||||||
|
public static readonly KEYCODE_H: number = 36;
|
||||||
|
public static readonly KEYCODE_HEADSETHOOK: number = 79;
|
||||||
|
public static readonly KEYCODE_HELP: number = 259;
|
||||||
|
public static readonly KEYCODE_HENKAN: number = 214;
|
||||||
|
public static readonly KEYCODE_HOME: number = 3;
|
||||||
|
public static readonly KEYCODE_I: number = 37;
|
||||||
|
public static readonly KEYCODE_INFO: number = 165;
|
||||||
|
public static readonly KEYCODE_INSERT: number = 124;
|
||||||
|
public static readonly KEYCODE_J: number = 38;
|
||||||
|
public static readonly KEYCODE_K: number = 39;
|
||||||
|
public static readonly KEYCODE_KANA: number = 218;
|
||||||
|
public static readonly KEYCODE_KATAKANA_HIRAGANA: number = 215;
|
||||||
|
public static readonly KEYCODE_L: number = 40;
|
||||||
|
public static readonly KEYCODE_LANGUAGE_SWITCH: number = 204;
|
||||||
|
public static readonly KEYCODE_LAST_CHANNEL: number = 229;
|
||||||
|
public static readonly KEYCODE_LEFT_BRACKET: number = 71;
|
||||||
|
public static readonly KEYCODE_M: number = 41;
|
||||||
|
public static readonly KEYCODE_MANNER_MODE: number = 205;
|
||||||
|
public static readonly KEYCODE_MEDIA_AUDIO_TRACK: number = 222;
|
||||||
|
public static readonly KEYCODE_MEDIA_CLOSE: number = 128;
|
||||||
|
public static readonly KEYCODE_MEDIA_EJECT: number = 129;
|
||||||
|
public static readonly KEYCODE_MEDIA_FAST_FORWARD: number = 90;
|
||||||
|
public static readonly KEYCODE_MEDIA_NEXT: number = 87;
|
||||||
|
public static readonly KEYCODE_MEDIA_PAUSE: number = 127;
|
||||||
|
public static readonly KEYCODE_MEDIA_PLAY: number = 126;
|
||||||
|
public static readonly KEYCODE_MEDIA_PLAY_PAUSE: number = 85;
|
||||||
|
public static readonly KEYCODE_MEDIA_PREVIOUS: number = 88;
|
||||||
|
public static readonly KEYCODE_MEDIA_RECORD: number = 130;
|
||||||
|
public static readonly KEYCODE_MEDIA_REWIND: number = 89;
|
||||||
|
public static readonly KEYCODE_MEDIA_SKIP_BACKWARD: number = 273;
|
||||||
|
public static readonly KEYCODE_MEDIA_SKIP_FORWARD: number = 272;
|
||||||
|
public static readonly KEYCODE_MEDIA_STEP_BACKWARD: number = 275;
|
||||||
|
public static readonly KEYCODE_MEDIA_STEP_FORWARD: number = 274;
|
||||||
|
public static readonly KEYCODE_MEDIA_STOP: number = 86;
|
||||||
|
public static readonly KEYCODE_MEDIA_TOP_MENU: number = 226;
|
||||||
|
public static readonly KEYCODE_MENU: number = 82;
|
||||||
|
public static readonly KEYCODE_META_LEFT: number = 117;
|
||||||
|
public static readonly KEYCODE_META_RIGHT: number = 118;
|
||||||
|
public static readonly KEYCODE_MINUS: number = 69;
|
||||||
|
public static readonly KEYCODE_MOVE_END: number = 123;
|
||||||
|
public static readonly KEYCODE_MOVE_HOME: number = 122;
|
||||||
|
public static readonly KEYCODE_MUHENKAN: number = 213;
|
||||||
|
public static readonly KEYCODE_MUSIC: number = 209;
|
||||||
|
public static readonly KEYCODE_MUTE: number = 91;
|
||||||
|
public static readonly KEYCODE_N: number = 42;
|
||||||
|
public static readonly KEYCODE_NAVIGATE_IN: number = 262;
|
||||||
|
public static readonly KEYCODE_NAVIGATE_NEXT: number = 261;
|
||||||
|
public static readonly KEYCODE_NAVIGATE_OUT: number = 263;
|
||||||
|
public static readonly KEYCODE_NAVIGATE_PREVIOUS: number = 260;
|
||||||
|
public static readonly KEYCODE_NOTIFICATION: number = 83;
|
||||||
|
public static readonly KEYCODE_NUM: number = 78;
|
||||||
|
public static readonly KEYCODE_NUMPAD_0: number = 144;
|
||||||
|
public static readonly KEYCODE_NUMPAD_1: number = 145;
|
||||||
|
public static readonly KEYCODE_NUMPAD_2: number = 146;
|
||||||
|
public static readonly KEYCODE_NUMPAD_3: number = 147;
|
||||||
|
public static readonly KEYCODE_NUMPAD_4: number = 148;
|
||||||
|
public static readonly KEYCODE_NUMPAD_5: number = 149;
|
||||||
|
public static readonly KEYCODE_NUMPAD_6: number = 150;
|
||||||
|
public static readonly KEYCODE_NUMPAD_7: number = 151;
|
||||||
|
public static readonly KEYCODE_NUMPAD_8: number = 152;
|
||||||
|
public static readonly KEYCODE_NUMPAD_9: number = 153;
|
||||||
|
public static readonly KEYCODE_NUMPAD_ADD: number = 157;
|
||||||
|
public static readonly KEYCODE_NUMPAD_COMMA: number = 159;
|
||||||
|
public static readonly KEYCODE_NUMPAD_DIVIDE: number = 154;
|
||||||
|
public static readonly KEYCODE_NUMPAD_DOT: number = 158;
|
||||||
|
public static readonly KEYCODE_NUMPAD_ENTER: number = 160;
|
||||||
|
public static readonly KEYCODE_NUMPAD_EQUALS: number = 161;
|
||||||
|
public static readonly KEYCODE_NUMPAD_LEFT_PAREN: number = 162;
|
||||||
|
public static readonly KEYCODE_NUMPAD_MULTIPLY: number = 155;
|
||||||
|
public static readonly KEYCODE_NUMPAD_RIGHT_PAREN: number = 163;
|
||||||
|
public static readonly KEYCODE_NUMPAD_SUBTRACT: number = 156;
|
||||||
|
public static readonly KEYCODE_NUM_LOCK: number = 143;
|
||||||
|
public static readonly KEYCODE_O: number = 43;
|
||||||
|
public static readonly KEYCODE_P: number = 44;
|
||||||
|
public static readonly KEYCODE_PAGE_DOWN: number = 93;
|
||||||
|
public static readonly KEYCODE_PAGE_UP: number = 92;
|
||||||
|
public static readonly KEYCODE_PAIRING: number = 225;
|
||||||
|
public static readonly KEYCODE_PASTE: number = 279;
|
||||||
|
public static readonly KEYCODE_PERIOD: number = 56;
|
||||||
|
public static readonly KEYCODE_PICTSYMBOLS: number = 94;
|
||||||
|
public static readonly KEYCODE_PLUS: number = 81;
|
||||||
|
public static readonly KEYCODE_POUND: number = 18;
|
||||||
|
public static readonly KEYCODE_POWER: number = 26;
|
||||||
|
public static readonly KEYCODE_PROFILE_SWITCH: number = 288;
|
||||||
|
public static readonly KEYCODE_PROG_BLUE: number = 186;
|
||||||
|
public static readonly KEYCODE_PROG_GREEN: number = 184;
|
||||||
|
public static readonly KEYCODE_PROG_RED: number = 183;
|
||||||
|
public static readonly KEYCODE_PROG_YELLOW: number = 185;
|
||||||
|
public static readonly KEYCODE_Q: number = 45;
|
||||||
|
public static readonly KEYCODE_R: number = 46;
|
||||||
|
public static readonly KEYCODE_REFRESH: number = 285;
|
||||||
|
public static readonly KEYCODE_RIGHT_BRACKET: number = 72;
|
||||||
|
public static readonly KEYCODE_RO: number = 217;
|
||||||
|
public static readonly KEYCODE_S: number = 47;
|
||||||
|
public static readonly KEYCODE_SCROLL_LOCK: number = 116;
|
||||||
|
public static readonly KEYCODE_SEARCH: number = 84;
|
||||||
|
public static readonly KEYCODE_SEMICOLON: number = 74;
|
||||||
|
public static readonly KEYCODE_SETTINGS: number = 176;
|
||||||
|
public static readonly KEYCODE_SHIFT_LEFT: number = 59;
|
||||||
|
public static readonly KEYCODE_SHIFT_RIGHT: number = 60;
|
||||||
|
public static readonly KEYCODE_SLASH: number = 76;
|
||||||
|
public static readonly KEYCODE_SLEEP: number = 223;
|
||||||
|
public static readonly KEYCODE_SOFT_LEFT: number = 1;
|
||||||
|
public static readonly KEYCODE_SOFT_RIGHT: number = 2;
|
||||||
|
public static readonly KEYCODE_SOFT_SLEEP: number = 276;
|
||||||
|
public static readonly KEYCODE_SPACE: number = 62;
|
||||||
|
public static readonly KEYCODE_STAR: number = 17;
|
||||||
|
public static readonly KEYCODE_STB_INPUT: number = 180;
|
||||||
|
public static readonly KEYCODE_STB_POWER: number = 179;
|
||||||
|
public static readonly KEYCODE_STEM_1: number = 265;
|
||||||
|
public static readonly KEYCODE_STEM_2: number = 266;
|
||||||
|
public static readonly KEYCODE_STEM_3: number = 267;
|
||||||
|
public static readonly KEYCODE_STEM_PRIMARY: number = 264;
|
||||||
|
public static readonly KEYCODE_SWITCH_CHARSET: number = 95;
|
||||||
|
public static readonly KEYCODE_SYM: number = 63;
|
||||||
|
public static readonly KEYCODE_SYSRQ: number = 120;
|
||||||
|
public static readonly KEYCODE_SYSTEM_NAVIGATION_DOWN: number = 281;
|
||||||
|
public static readonly KEYCODE_SYSTEM_NAVIGATION_LEFT: number = 282;
|
||||||
|
public static readonly KEYCODE_SYSTEM_NAVIGATION_RIGHT: number = 283;
|
||||||
|
public static readonly KEYCODE_SYSTEM_NAVIGATION_UP: number = 280;
|
||||||
|
public static readonly KEYCODE_T: number = 48;
|
||||||
|
public static readonly KEYCODE_TAB: number = 61;
|
||||||
|
public static readonly KEYCODE_THUMBS_DOWN: number = 287;
|
||||||
|
public static readonly KEYCODE_THUMBS_UP: number = 286;
|
||||||
|
public static readonly KEYCODE_TV: number = 170;
|
||||||
|
public static readonly KEYCODE_TV_ANTENNA_CABLE: number = 242;
|
||||||
|
public static readonly KEYCODE_TV_AUDIO_DESCRIPTION: number = 252;
|
||||||
|
public static readonly KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN: number = 254;
|
||||||
|
public static readonly KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP: number = 253;
|
||||||
|
public static readonly KEYCODE_TV_CONTENTS_MENU: number = 256;
|
||||||
|
public static readonly KEYCODE_TV_DATA_SERVICE: number = 230;
|
||||||
|
public static readonly KEYCODE_TV_INPUT: number = 178;
|
||||||
|
public static readonly KEYCODE_TV_INPUT_COMPONENT_1: number = 249;
|
||||||
|
public static readonly KEYCODE_TV_INPUT_COMPONENT_2: number = 250;
|
||||||
|
public static readonly KEYCODE_TV_INPUT_COMPOSITE_1: number = 247;
|
||||||
|
public static readonly KEYCODE_TV_INPUT_COMPOSITE_2: number = 248;
|
||||||
|
public static readonly KEYCODE_TV_INPUT_HDMI_1: number = 243;
|
||||||
|
public static readonly KEYCODE_TV_INPUT_HDMI_2: number = 244;
|
||||||
|
public static readonly KEYCODE_TV_INPUT_HDMI_3: number = 245;
|
||||||
|
public static readonly KEYCODE_TV_INPUT_HDMI_4: number = 246;
|
||||||
|
public static readonly KEYCODE_TV_INPUT_VGA_1: number = 251;
|
||||||
|
public static readonly KEYCODE_TV_MEDIA_CONTEXT_MENU: number = 257;
|
||||||
|
public static readonly KEYCODE_TV_NETWORK: number = 241;
|
||||||
|
public static readonly KEYCODE_TV_NUMBER_ENTRY: number = 234;
|
||||||
|
public static readonly KEYCODE_TV_POWER: number = 177;
|
||||||
|
public static readonly KEYCODE_TV_RADIO_SERVICE: number = 232;
|
||||||
|
public static readonly KEYCODE_TV_SATELLITE: number = 237;
|
||||||
|
public static readonly KEYCODE_TV_SATELLITE_BS: number = 238;
|
||||||
|
public static readonly KEYCODE_TV_SATELLITE_CS: number = 239;
|
||||||
|
public static readonly KEYCODE_TV_SATELLITE_SERVICE: number = 240;
|
||||||
|
public static readonly KEYCODE_TV_TELETEXT: number = 233;
|
||||||
|
public static readonly KEYCODE_TV_TERRESTRIAL_ANALOG: number = 235;
|
||||||
|
public static readonly KEYCODE_TV_TERRESTRIAL_DIGITAL: number = 236;
|
||||||
|
public static readonly KEYCODE_TV_TIMER_PROGRAMMING: number = 258;
|
||||||
|
public static readonly KEYCODE_TV_ZOOM_MODE: number = 255;
|
||||||
|
public static readonly KEYCODE_U: number = 49;
|
||||||
|
public static readonly KEYCODE_UNKNOWN: number = 0;
|
||||||
|
public static readonly KEYCODE_V: number = 50;
|
||||||
|
public static readonly KEYCODE_VOICE_ASSIST: number = 231;
|
||||||
|
public static readonly KEYCODE_VOLUME_DOWN: number = 25;
|
||||||
|
public static readonly KEYCODE_VOLUME_MUTE: number = 164;
|
||||||
|
public static readonly KEYCODE_VOLUME_UP: number = 24;
|
||||||
|
public static readonly KEYCODE_W: number = 51;
|
||||||
|
public static readonly KEYCODE_WAKEUP: number = 224;
|
||||||
|
public static readonly KEYCODE_WINDOW: number = 171;
|
||||||
|
public static readonly KEYCODE_X: number = 52;
|
||||||
|
public static readonly KEYCODE_Y: number = 53;
|
||||||
|
public static readonly KEYCODE_YEN: number = 216;
|
||||||
|
public static readonly KEYCODE_Z: number = 54;
|
||||||
|
public static readonly KEYCODE_ZENKAKU_HANKAKU: number = 211;
|
||||||
|
public static readonly KEYCODE_ZOOM_IN: number = 168;
|
||||||
|
public static readonly KEYCODE_ZOOM_OUT: number = 169;
|
||||||
|
|
||||||
|
public static readonly META_ALT_LEFT_ON: number = 16;
|
||||||
|
public static readonly META_ALT_MASK: number = 50;
|
||||||
|
public static readonly META_ALT_ON: number = 2;
|
||||||
|
public static readonly META_ALT_RIGHT_ON: number = 32;
|
||||||
|
public static readonly META_CAPS_LOCK_ON: number = 1048576;
|
||||||
|
public static readonly META_CTRL_LEFT_ON: number = 8192;
|
||||||
|
public static readonly META_CTRL_MASK: number = 28672;
|
||||||
|
public static readonly META_CTRL_ON: number = 4096;
|
||||||
|
public static readonly META_CTRL_RIGHT_ON: number = 16384;
|
||||||
|
public static readonly META_FUNCTION_ON: number = 8;
|
||||||
|
public static readonly META_META_LEFT_ON: number = 131072;
|
||||||
|
public static readonly META_META_MASK: number = 458752;
|
||||||
|
public static readonly META_META_ON: number = 65536;
|
||||||
|
public static readonly META_META_RIGHT_ON: number = 262144;
|
||||||
|
public static readonly META_NUM_LOCK_ON: number = 2097152;
|
||||||
|
public static readonly META_SCROLL_LOCK_ON: number = 4194304;
|
||||||
|
public static readonly META_SHIFT_LEFT_ON: number = 64;
|
||||||
|
public static readonly META_SHIFT_MASK: number = 193;
|
||||||
|
public static readonly META_SHIFT_ON: number = 1;
|
||||||
|
public static readonly META_SHIFT_RIGHT_ON: number = 128;
|
||||||
|
public static readonly META_SYM_ON: number = 4;
|
||||||
|
}
|
||||||
935
src/app/googDevice/android/MediaFormat.ts
Normal file
935
src/app/googDevice/android/MediaFormat.ts
Normal file
@@ -0,0 +1,935 @@
|
|||||||
|
export default class MediaFormat {
|
||||||
|
public static readonly MIMETYPE_VIDEO_VP8: string = 'video/x-vnd.on2.vp8';
|
||||||
|
public static readonly MIMETYPE_VIDEO_VP9: string = 'video/x-vnd.on2.vp9';
|
||||||
|
public static readonly MIMETYPE_VIDEO_AV1: string = 'video/av01';
|
||||||
|
public static readonly MIMETYPE_VIDEO_AVC: string = 'video/avc';
|
||||||
|
public static readonly MIMETYPE_VIDEO_HEVC: string = 'video/hevc';
|
||||||
|
public static readonly MIMETYPE_VIDEO_MPEG4: string = 'video/mp4v-es';
|
||||||
|
public static readonly MIMETYPE_VIDEO_H263: string = 'video/3gpp';
|
||||||
|
public static readonly MIMETYPE_VIDEO_MPEG2: string = 'video/mpeg2';
|
||||||
|
public static readonly MIMETYPE_VIDEO_RAW: string = 'video/raw';
|
||||||
|
public static readonly MIMETYPE_VIDEO_DOLBY_VISION: string = 'video/dolby-vision';
|
||||||
|
public static readonly MIMETYPE_VIDEO_SCRAMBLED: string = 'video/scrambled';
|
||||||
|
|
||||||
|
public static readonly MIMETYPE_AUDIO_AMR_NB: string = 'audio/3gpp';
|
||||||
|
public static readonly MIMETYPE_AUDIO_AMR_WB: string = 'audio/amr-wb';
|
||||||
|
public static readonly MIMETYPE_AUDIO_MPEG: string = 'audio/mpeg';
|
||||||
|
public static readonly MIMETYPE_AUDIO_AAC: string = 'audio/mp4a-latm';
|
||||||
|
public static readonly MIMETYPE_AUDIO_QCELP: string = 'audio/qcelp';
|
||||||
|
public static readonly MIMETYPE_AUDIO_VORBIS: string = 'audio/vorbis';
|
||||||
|
public static readonly MIMETYPE_AUDIO_OPUS: string = 'audio/opus';
|
||||||
|
public static readonly MIMETYPE_AUDIO_G711_ALAW: string = 'audio/g711-alaw';
|
||||||
|
public static readonly MIMETYPE_AUDIO_G711_MLAW: string = 'audio/g711-mlaw';
|
||||||
|
public static readonly MIMETYPE_AUDIO_RAW: string = 'audio/raw';
|
||||||
|
public static readonly MIMETYPE_AUDIO_FLAC: string = 'audio/flac';
|
||||||
|
public static readonly MIMETYPE_AUDIO_MSGSM: string = 'audio/gsm';
|
||||||
|
public static readonly MIMETYPE_AUDIO_AC3: string = 'audio/ac3';
|
||||||
|
public static readonly MIMETYPE_AUDIO_EAC3: string = 'audio/eac3';
|
||||||
|
public static readonly MIMETYPE_AUDIO_EAC3_JOC: string = 'audio/eac3-joc';
|
||||||
|
public static readonly MIMETYPE_AUDIO_AC4: string = 'audio/ac4';
|
||||||
|
public static readonly MIMETYPE_AUDIO_SCRAMBLED: string = 'audio/scrambled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME type for HEIF still image data encoded in HEVC.
|
||||||
|
*
|
||||||
|
* To decode such an image, {@link MediaCodec} decoder for
|
||||||
|
* {@link #MIMETYPE_VIDEO_HEVC} shall be used. The client needs to form
|
||||||
|
* the correct {@link #MediaFormat} based on additional information in
|
||||||
|
* the track format, and send it to {@link MediaCodec#configure}.
|
||||||
|
*
|
||||||
|
* The track's MediaFormat will come with {@link #KEY_WIDTH} and
|
||||||
|
* {@link #KEY_HEIGHT} keys, which describes the width and height
|
||||||
|
* of the image. If the image doesn't contain grid (i.e. none of
|
||||||
|
* {@link #KEY_TILE_WIDTH}, {@link #KEY_TILE_HEIGHT},
|
||||||
|
* {@link #KEY_GRID_ROWS}, {@link #KEY_GRID_COLUMNS} are present}), the
|
||||||
|
* track will contain a single sample of coded data for the entire image,
|
||||||
|
* and the image width and height should be used to set up the decoder.
|
||||||
|
*
|
||||||
|
* If the image does come with grid, each sample from the track will
|
||||||
|
* contain one tile in the grid, of which the size is described by
|
||||||
|
* {@link #KEY_TILE_WIDTH} and {@link #KEY_TILE_HEIGHT}. This size
|
||||||
|
* (instead of {@link #KEY_WIDTH} and {@link #KEY_HEIGHT}) should be
|
||||||
|
* used to set up the decoder. The track contains {@link #KEY_GRID_ROWS}
|
||||||
|
* by {@link #KEY_GRID_COLUMNS} samples in row-major, top-row first,
|
||||||
|
* left-to-right order. The output image should be reconstructed by
|
||||||
|
* first tiling the decoding results of the tiles in the correct order,
|
||||||
|
* then trimming (before rotation is applied) on the bottom and right
|
||||||
|
* side, if the tiled area is larger than the image width and height.
|
||||||
|
*/
|
||||||
|
public static readonly MIMETYPE_IMAGE_ANDROID_HEIC: string = 'image/vnd.android.heic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME type for WebVTT subtitle data.
|
||||||
|
*/
|
||||||
|
public static readonly MIMETYPE_TEXT_VTT: string = 'text/vtt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME type for SubRip (SRT) container.
|
||||||
|
*/
|
||||||
|
public static readonly MIMETYPE_TEXT_SUBRIP: string = 'application/x-subrip';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME type for CEA-608 closed caption data.
|
||||||
|
*/
|
||||||
|
public static readonly MIMETYPE_TEXT_CEA_608: string = 'text/cea-608';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME type for CEA-708 closed caption data.
|
||||||
|
*/
|
||||||
|
public static readonly MIMETYPE_TEXT_CEA_708: string = 'text/cea-708';
|
||||||
|
|
||||||
|
// private mMap: Map<String, Object> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the mime type of the MediaFormat.
|
||||||
|
* The associated value is a string.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_MIME: string = 'mime';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the language of the content, using either ISO 639-1
|
||||||
|
* or 639-2/T codes. The associated value is a string.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_LANGUAGE: string = 'language';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the sample rate of an audio format.
|
||||||
|
* The associated value is an integer
|
||||||
|
*/
|
||||||
|
public static readonly KEY_SAMPLE_RATE: string = 'sample-rate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the number of channels in an audio format.
|
||||||
|
* The associated value is an integer
|
||||||
|
*/
|
||||||
|
public static readonly KEY_CHANNEL_COUNT: string = 'channel-count';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the width of the content in a video format.
|
||||||
|
* The associated value is an integer
|
||||||
|
*/
|
||||||
|
public static readonly KEY_WIDTH: string = 'width';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the height of the content in a video format.
|
||||||
|
* The associated value is an integer
|
||||||
|
*/
|
||||||
|
public static readonly KEY_HEIGHT: string = 'height';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the maximum expected width of the content in a video
|
||||||
|
* decoder format, in case there are resolution changes in the video content.
|
||||||
|
* The associated value is an integer
|
||||||
|
*/
|
||||||
|
public static readonly KEY_MAX_WIDTH: string = 'max-width';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the maximum expected height of the content in a video
|
||||||
|
* decoder format, in case there are resolution changes in the video content.
|
||||||
|
* The associated value is an integer
|
||||||
|
*/
|
||||||
|
public static readonly KEY_MAX_HEIGHT: string = 'max-height';
|
||||||
|
|
||||||
|
/** A key describing the maximum size in bytes of a buffer of data
|
||||||
|
* described by this MediaFormat.
|
||||||
|
* The associated value is an integer
|
||||||
|
*/
|
||||||
|
public static readonly KEY_MAX_INPUT_SIZE: string = 'max-input-size';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the average bitrate in bits/sec.
|
||||||
|
* The associated value is an integer
|
||||||
|
*/
|
||||||
|
public static readonly KEY_BIT_RATE: string = 'bitrate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the max bitrate in bits/sec.
|
||||||
|
* This is usually over a one-second sliding window (e.g. over any window of one second).
|
||||||
|
* The associated value is an integer
|
||||||
|
* @hide
|
||||||
|
*/
|
||||||
|
public static readonly KEY_MAX_BIT_RATE: string = 'max-bitrate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the color format of the content in a video format.
|
||||||
|
* Constants are declared in {@link android.media.MediaCodecInfo.CodecCapabilities}.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_COLOR_FORMAT: string = 'color-format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the frame rate of a video format in frames/sec.
|
||||||
|
* The associated value is normally an integer when the value is used by the platform,
|
||||||
|
* but video codecs also accept float configuration values.
|
||||||
|
* Specifically, {@link MediaExtractor#getTrackFormat MediaExtractor} provides an integer
|
||||||
|
* value corresponding to the frame rate information of the track if specified and non-zero.
|
||||||
|
* Otherwise, this key is not present. {@link MediaCodec#configure MediaCodec} accepts both
|
||||||
|
* float and integer values. This represents the desired operating frame rate if the
|
||||||
|
* {@link #KEY_OPERATING_RATE} is not present and {@link #KEY_PRIORITY} is {@code 0}
|
||||||
|
* (realtime). For video encoders this value corresponds to the intended frame rate,
|
||||||
|
* although encoders are expected
|
||||||
|
* to support variable frame rate based on {@link MediaCodec.BufferInfo#presentationTimeUs
|
||||||
|
* buffer timestamp}. This key is not used in the {@code MediaCodec}
|
||||||
|
* {@link MediaCodec#getInputFormat input}/{@link MediaCodec#getOutputFormat output} formats,
|
||||||
|
* nor by {@link MediaMuxer#addTrack MediaMuxer}.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_FRAME_RATE: string = 'frame-rate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the width (in pixels) of each tile of the content in a
|
||||||
|
* {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
|
||||||
|
*
|
||||||
|
* Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
|
||||||
|
*
|
||||||
|
* @see #KEY_TILE_HEIGHT
|
||||||
|
* @see #KEY_GRID_ROWS
|
||||||
|
* @see #KEY_GRID_COLUMNS
|
||||||
|
*/
|
||||||
|
public static readonly KEY_TILE_WIDTH: string = 'tile-width';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the height (in pixels) of each tile of the content in a
|
||||||
|
* {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
|
||||||
|
*
|
||||||
|
* Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
|
||||||
|
*
|
||||||
|
* @see #KEY_TILE_WIDTH
|
||||||
|
* @see #KEY_GRID_ROWS
|
||||||
|
* @see #KEY_GRID_COLUMNS
|
||||||
|
*/
|
||||||
|
public static readonly KEY_TILE_HEIGHT: string = 'tile-height';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the number of grid rows in the content in a
|
||||||
|
* {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
|
||||||
|
*
|
||||||
|
* Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
|
||||||
|
*
|
||||||
|
* @see #KEY_TILE_WIDTH
|
||||||
|
* @see #KEY_TILE_HEIGHT
|
||||||
|
* @see #KEY_GRID_COLUMNS
|
||||||
|
*/
|
||||||
|
public static readonly KEY_GRID_ROWS: string = 'grid-rows';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the number of grid columns in the content in a
|
||||||
|
* {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
|
||||||
|
*
|
||||||
|
* Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
|
||||||
|
*
|
||||||
|
* @see #KEY_TILE_WIDTH
|
||||||
|
* @see #KEY_TILE_HEIGHT
|
||||||
|
* @see #KEY_GRID_ROWS
|
||||||
|
*/
|
||||||
|
public static readonly KEY_GRID_COLUMNS: string = 'grid-cols';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the raw audio sample encoding/format.
|
||||||
|
*
|
||||||
|
* <p>The associated value is an integer, using one of the
|
||||||
|
* {@link AudioFormat}.ENCODING_PCM_ values.</p>
|
||||||
|
*
|
||||||
|
* <p>This is an optional key for audio decoders and encoders specifying the
|
||||||
|
* desired raw audio sample format during {@link MediaCodec#configure
|
||||||
|
* MediaCodec.configure(…)} call. Use {@link MediaCodec#getInputFormat
|
||||||
|
* MediaCodec.getInput}/{@link MediaCodec#getOutputFormat OutputFormat(…)}
|
||||||
|
* to confirm the actual format. For the PCM decoder this key specifies both
|
||||||
|
* input and output sample encodings.</p>
|
||||||
|
*
|
||||||
|
* <p>This key is also used by {@link MediaExtractor} to specify the sample
|
||||||
|
* format of audio data, if it is specified.</p>
|
||||||
|
*
|
||||||
|
* <p>If this key is missing, the raw audio sample format is signed 16-bit short.</p>
|
||||||
|
*/
|
||||||
|
public static readonly KEY_PCM_ENCODING: string = 'pcm-encoding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the capture rate of a video format in frames/sec.
|
||||||
|
* <p>
|
||||||
|
* When capture rate is different than the frame rate, it means that the
|
||||||
|
* video is acquired at a different rate than the playback, which produces
|
||||||
|
* slow motion or timelapse effect during playback. Application can use the
|
||||||
|
* value of this key to tell the relative speed ratio between capture and
|
||||||
|
* playback rates when the video was recorded.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* The associated value is an integer or a float.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public static readonly KEY_CAPTURE_RATE: string = 'capture-rate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the frequency of key frames expressed in seconds between key frames.
|
||||||
|
* <p>
|
||||||
|
* This key is used by video encoders.
|
||||||
|
* A negative value means no key frames are requested after the first frame.
|
||||||
|
* A zero value means a stream containing all key frames is requested.
|
||||||
|
* <p class=note>
|
||||||
|
* Most video encoders will convert this value of the number of non-key-frames between
|
||||||
|
* key-frames, using the {@linkplain #KEY_FRAME_RATE frame rate} information; therefore,
|
||||||
|
* if the actual frame rate differs (e.g. input frames are dropped or the frame rate
|
||||||
|
* changes), the <strong>time interval</strong> between key frames will not be the
|
||||||
|
* configured value.
|
||||||
|
* <p>
|
||||||
|
* The associated value is an integer (or float since
|
||||||
|
* {@link android.os.Build.VERSION_CODES#N_MR1}).
|
||||||
|
*/
|
||||||
|
public static readonly KEY_I_FRAME_INTERVAL: string = 'i-frame-interval';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional key describing the period of intra refresh in frames. This is an
|
||||||
|
* optional parameter that applies only to video encoders. If encoder supports it
|
||||||
|
* ({@link MediaCodecInfo.CodecCapabilities#FEATURE_IntraRefresh}), the whole
|
||||||
|
* frame is completely refreshed after the specified period. Also for each frame,
|
||||||
|
* a fix subset of macroblocks must be intra coded which leads to more constant bitrate
|
||||||
|
* than inserting a key frame. This key is recommended for video streaming applications
|
||||||
|
* as it provides low-delay and good error-resilience. This key is ignored if the
|
||||||
|
* video encoder does not support the intra refresh feature. Use the output format to
|
||||||
|
* verify that this feature was enabled.
|
||||||
|
* The associated value is an integer.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_INTRA_REFRESH_PERIOD: string = 'intra-refresh-period';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional key describing whether encoders prepend headers to sync frames (e.g.
|
||||||
|
* SPS and PPS to IDR frames for H.264). This is an optional parameter that applies only
|
||||||
|
* to video encoders. A video encoder may not support this feature; the component will fail
|
||||||
|
* to configure in that case. For other components, this key is ignored.
|
||||||
|
*
|
||||||
|
* The value is an integer, with 1 indicating to prepend headers to every sync frames,
|
||||||
|
* or 0 otherwise. The default value is 0.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_PREPEND_HEADER_TO_SYNC_FRAMES: string = 'prepend-sps-pps-to-idr-frames';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the temporal layering schema. This is an optional parameter
|
||||||
|
* that applies only to video encoders. Use {@link MediaCodec#getOutputFormat}
|
||||||
|
* after {@link MediaCodec#configure configure} to query if the encoder supports
|
||||||
|
* the desired schema. Supported values are {@code webrtc.vp8.N-layer},
|
||||||
|
* {@code android.generic.N}, {@code android.generic.N+M} and {@code none}, where
|
||||||
|
* {@code N} denotes the total number of non-bidirectional layers (which must be at least 1)
|
||||||
|
* and {@code M} denotes the total number of bidirectional layers (which must be non-negative).
|
||||||
|
* <p class=note>{@code android.generic.*} schemas have been added in {@link
|
||||||
|
* android.os.Build.VERSION_CODES#N_MR1}.
|
||||||
|
* <p>
|
||||||
|
* The encoder may support fewer temporal layers, in which case the output format
|
||||||
|
* will contain the configured schema. If the encoder does not support temporal
|
||||||
|
* layering, the output format will not have an entry with this key.
|
||||||
|
* The associated value is a string.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_TEMPORAL_LAYERING: string = 'ts-schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the stride of the video bytebuffer layout.
|
||||||
|
* Stride (or row increment) is the difference between the index of a pixel
|
||||||
|
* and that of the pixel directly underneath. For YUV 420 formats, the
|
||||||
|
* stride corresponds to the Y plane; the stride of the U and V planes can
|
||||||
|
* be calculated based on the color format, though it is generally undefined
|
||||||
|
* and depends on the device and release.
|
||||||
|
* The associated value is an integer, representing number of bytes.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_STRIDE: string = 'stride';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the plane height of a multi-planar (YUV) video bytebuffer layout.
|
||||||
|
* Slice height (or plane height/vertical stride) is the number of rows that must be skipped
|
||||||
|
* to get from the top of the Y plane to the top of the U plane in the bytebuffer. In essence
|
||||||
|
* the offset of the U plane is sliceHeight * stride. The height of the U/V planes
|
||||||
|
* can be calculated based on the color format, though it is generally undefined
|
||||||
|
* and depends on the device and release.
|
||||||
|
* The associated value is an integer, representing number of rows.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_SLICE_HEIGHT: string = 'slice-height';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies only when configuring a video encoder in "surface-input" mode.
|
||||||
|
* The associated value is a long and gives the time in microseconds
|
||||||
|
* after which the frame previously submitted to the encoder will be
|
||||||
|
* repeated (once) if no new frame became available since.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_REPEAT_PREVIOUS_FRAME_AFTER: string = 'repeat-previous-frame-after';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instruct the video encoder in "surface-input" mode to drop excessive
|
||||||
|
* frames from the source, so that the input frame rate to the encoder
|
||||||
|
* does not exceed the specified fps.
|
||||||
|
*
|
||||||
|
* The associated value is a float, representing the max frame rate to
|
||||||
|
* feed the encoder at.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static readonly KEY_MAX_FPS_TO_ENCODER: string = 'max-fps-to-encoder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instruct the video encoder in "surface-input" mode to limit the gap of
|
||||||
|
* timestamp between any two adjacent frames fed to the encoder to the
|
||||||
|
* specified amount (in micro-second).
|
||||||
|
*
|
||||||
|
* The associated value is a long int. When positive, it represents the max
|
||||||
|
* timestamp gap between two adjacent frames fed to the encoder. When negative,
|
||||||
|
* the absolute value represents a fixed timestamp gap between any two adjacent
|
||||||
|
* frames fed to the encoder. Note that this will also apply even when the
|
||||||
|
* original timestamp goes backward in time. Under normal conditions, such frames
|
||||||
|
* would be dropped and not sent to the encoder.
|
||||||
|
*
|
||||||
|
* The output timestamp will be restored to the original timestamp and will
|
||||||
|
* not be affected.
|
||||||
|
*
|
||||||
|
* This is used in some special scenarios where input frames arrive sparingly
|
||||||
|
* but it's undesirable to allocate more bits to any single frame, or when it's
|
||||||
|
* important to ensure all frames are captured (rather than captured in the
|
||||||
|
* correct order).
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static readonly KEY_MAX_PTS_GAP_TO_ENCODER: string = 'max-pts-gap-to-encoder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If specified when configuring a video encoder that's in "surface-input"
|
||||||
|
* mode, it will instruct the encoder to put the surface source in suspended
|
||||||
|
* state when it's connected. No video frames will be accepted until a resume
|
||||||
|
* operation (see {@link MediaCodec#PARAMETER_KEY_SUSPEND}), optionally with
|
||||||
|
* timestamp specified via {@link MediaCodec#PARAMETER_KEY_SUSPEND_TIME}, is
|
||||||
|
* received.
|
||||||
|
*
|
||||||
|
* The value is an integer, with 1 indicating to create with the surface
|
||||||
|
* source suspended, or 0 otherwise. The default value is 0.
|
||||||
|
*
|
||||||
|
* If this key is not set or set to 0, the surface source will accept buffers
|
||||||
|
* as soon as it's connected to the encoder (although they may not be encoded
|
||||||
|
* immediately). This key can be used when the client wants to prepare the
|
||||||
|
* encoder session in advance, but do not want to accept buffers immediately.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_CREATE_INPUT_SURFACE_SUSPENDED: string = 'create-input-buffers-suspended';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If specified when configuring a video decoder rendering to a surface,
|
||||||
|
* causes the decoder to output "blank", i.e. black frames to the surface
|
||||||
|
* when stopped to clear out any previously displayed contents.
|
||||||
|
* The associated value is an integer of value 1.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_PUSH_BLANK_BUFFERS_ON_STOP: string = 'push-blank-buffers-on-shutdown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the duration (in microseconds) of the content.
|
||||||
|
* The associated value is a long.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_DURATION: string = 'durationUs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key mapping to a value of 1 if the content is AAC audio and
|
||||||
|
* audio frames are prefixed with an ADTS header.
|
||||||
|
* The associated value is an integer (0 or 1).
|
||||||
|
* This key is only supported when _decoding_ content, it cannot
|
||||||
|
* be used to configure an encoder to emit ADTS output.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_IS_ADTS: string = 'is-adts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the channel composition of audio content. This mask
|
||||||
|
* is composed of bits drawn from channel mask definitions in {@link android.media.AudioFormat}.
|
||||||
|
* The associated value is an integer.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_CHANNEL_MASK: string = 'channel-mask';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the AAC profile to be used (AAC audio formats only).
|
||||||
|
* Constants are declared in {@link android.media.MediaCodecInfo.CodecProfileLevel}.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AAC_PROFILE: string = 'aac-profile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the AAC SBR mode to be used (AAC audio formats only).
|
||||||
|
* The associated value is an integer and can be set to following values:
|
||||||
|
* <ul>
|
||||||
|
* <li>0 - no SBR should be applied</li>
|
||||||
|
* <li>1 - single rate SBR</li>
|
||||||
|
* <li>2 - double rate SBR</li>
|
||||||
|
* </ul>
|
||||||
|
* Note: If this key is not defined the default SRB mode for the desired AAC profile will
|
||||||
|
* be used.
|
||||||
|
* <p>This key is only used during encoding.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AAC_SBR_MODE: string = 'aac-sbr-mode';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the maximum number of channels that can be output by the AAC decoder.
|
||||||
|
* By default, the decoder will output the same number of channels as present in the encoded
|
||||||
|
* stream, if supported. Set this value to limit the number of output channels, and use
|
||||||
|
* the downmix information in the stream, if available.
|
||||||
|
* <p>Values larger than the number of channels in the content to decode are ignored.
|
||||||
|
* <p>This key is only used during decoding.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AAC_MAX_OUTPUT_CHANNEL_COUNT: string = 'aac-max-output-channel_count';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing a gain to be applied so that the output loudness matches the
|
||||||
|
* Target Reference Level. This is typically used to normalize loudness across program items.
|
||||||
|
* The gain is derived as the difference between the Target Reference Level and the
|
||||||
|
* Program Reference Level. The latter can be given in the bitstream and indicates the actual
|
||||||
|
* loudness value of the program item.
|
||||||
|
* <p>The Target Reference Level controls loudness normalization for both MPEG-4 DRC and
|
||||||
|
* MPEG-D DRC.
|
||||||
|
* <p>The value is given as an integer value between
|
||||||
|
* 40 and 127, and is calculated as -4 * Target Reference Level in LKFS.
|
||||||
|
* Therefore, it represents the range of -10 to -31.75 LKFS.
|
||||||
|
* <p>The default value on mobile devices is 64 (-16 LKFS).
|
||||||
|
* <p>This key is only used during decoding.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AAC_DRC_TARGET_REFERENCE_LEVEL: string = 'aac-target-ref-level';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing for selecting the DRC effect type for MPEG-D DRC.
|
||||||
|
* The supported values are defined in ISO/IEC 23003-4:2015 and are described as follows:
|
||||||
|
* <table>
|
||||||
|
* <tr><th>Value</th><th>Effect</th></tr>
|
||||||
|
* <tr><th>-1</th><th>Off</th></tr>
|
||||||
|
* <tr><th>0</th><th>None</th></tr>
|
||||||
|
* <tr><th>1</th><th>Late night</th></tr>
|
||||||
|
* <tr><th>2</th><th>Noisy environment</th></tr>
|
||||||
|
* <tr><th>3</th><th>Limited playback range</th></tr>
|
||||||
|
* <tr><th>4</th><th>Low playback level</th></tr>
|
||||||
|
* <tr><th>5</th><th>Dialog enhancement</th></tr>
|
||||||
|
* <tr><th>6</th><th>General compression</th></tr>
|
||||||
|
* </table>
|
||||||
|
* <p>The value -1 (Off) disables DRC processing, while loudness normalization may still be
|
||||||
|
* active and dependent on KEY_AAC_DRC_TARGET_REFERENCE_LEVEL.<br>
|
||||||
|
* The value 0 (None) automatically enables DRC processing if necessary to prevent signal
|
||||||
|
* clipping<br>
|
||||||
|
* The value 6 (General compression) can be used for enabling MPEG-D DRC without particular
|
||||||
|
* DRC effect type request.<br>
|
||||||
|
* The default DRC effect type is 3 ("Limited playback range") on mobile devices.
|
||||||
|
* <p>This key is only used during decoding.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AAC_DRC_EFFECT_TYPE: string = 'aac-drc-effect-type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the target reference level that was assumed at the encoder for
|
||||||
|
* calculation of attenuation gains for clipping prevention.
|
||||||
|
* <p>If it is known, this information can be provided as an integer value between
|
||||||
|
* 0 and 127, which is calculated as -4 * Encoded Target Level in LKFS.
|
||||||
|
* If the Encoded Target Level is unknown, the value can be set to -1.
|
||||||
|
* <p>The default value is -1 (unknown).
|
||||||
|
* <p>The value is ignored when heavy compression is used (see
|
||||||
|
* {@link #KEY_AAC_DRC_HEAVY_COMPRESSION}).
|
||||||
|
* <p>This key is only used during decoding.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AAC_ENCODED_TARGET_LEVEL: string = 'aac-encoded-target-level';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the boost factor allowing to adapt the dynamics of the output to the
|
||||||
|
* actual listening requirements. This relies on DRC gain sequences that can be transmitted in
|
||||||
|
* the encoded bitstream to be able to reduce the dynamics of the output signal upon request.
|
||||||
|
* This factor enables the user to select how much of the gains are applied.
|
||||||
|
* <p>Positive gains (boost) and negative gains (attenuation, see
|
||||||
|
* {@link #KEY_AAC_DRC_ATTENUATION_FACTOR}) can be controlled separately for a better match
|
||||||
|
* to different use-cases.
|
||||||
|
* <p>Typically, attenuation gains are sent for loud signal segments, and boost gains are sent
|
||||||
|
* for soft signal segments. If the output is listened to in a noisy environment, for example,
|
||||||
|
* the boost factor is used to enable the positive gains, i.e. to amplify soft signal segments
|
||||||
|
* beyond the noise floor. But for listening late at night, the attenuation
|
||||||
|
* factor is used to enable the negative gains, to prevent loud signal from surprising
|
||||||
|
* the listener. In applications which generally need a low dynamic range, both the boost factor
|
||||||
|
* and the attenuation factor are used in order to enable all DRC gains.
|
||||||
|
* <p>In order to prevent clipping, it is also recommended to apply the attenuation gains
|
||||||
|
* in case of a downmix and/or loudness normalization to high target reference levels.
|
||||||
|
* <p>Both the boost and the attenuation factor parameters are given as integer values
|
||||||
|
* between 0 and 127, representing the range of the factor of 0 (i.e. don't apply)
|
||||||
|
* to 1 (i.e. fully apply boost/attenuation gains respectively).
|
||||||
|
* <p>The default value is 127 (fully apply boost DRC gains).
|
||||||
|
* <p>This key is only used during decoding.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AAC_DRC_BOOST_FACTOR: string = 'aac-drc-boost-level';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the attenuation factor allowing to adapt the dynamics of the output to the
|
||||||
|
* actual listening requirements.
|
||||||
|
* See {@link #KEY_AAC_DRC_BOOST_FACTOR} for a description of the role of this attenuation
|
||||||
|
* factor and the value range.
|
||||||
|
* <p>The default value is 127 (fully apply attenuation DRC gains).
|
||||||
|
* <p>This key is only used during decoding.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AAC_DRC_ATTENUATION_FACTOR: string = 'aac-drc-cut-level';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the selection of the heavy compression profile for DRC.
|
||||||
|
* Two separate DRC gain sequences can be transmitted in one bitstream: MPEG-4 DRC light
|
||||||
|
* compression, and DVB-specific heavy compression. When selecting the application of the heavy
|
||||||
|
* compression, one of the sequences is selected:
|
||||||
|
* <ul>
|
||||||
|
* <li>0 enables light compression,</li>
|
||||||
|
* <li>1 enables heavy compression instead.
|
||||||
|
* </ul>
|
||||||
|
* Note that only light compression offers the features of scaling of DRC gains
|
||||||
|
* (see {@link #KEY_AAC_DRC_BOOST_FACTOR} and {@link #KEY_AAC_DRC_ATTENUATION_FACTOR} for the
|
||||||
|
* boost and attenuation factors, and frequency-selective (multiband) DRC.
|
||||||
|
* Light compression usually contains clipping prevention for stereo downmixing while heavy
|
||||||
|
* compression, if additionally provided in the bitstream, is usually stronger, and contains
|
||||||
|
* clipping prevention for stereo and mono downmixing.
|
||||||
|
* <p>The default is 1 (heavy compression).
|
||||||
|
* <p>This key is only used during decoding.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AAC_DRC_HEAVY_COMPRESSION: string = 'aac-drc-heavy-compression';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the FLAC compression level to be used (FLAC audio format only).
|
||||||
|
* The associated value is an integer ranging from 0 (fastest, least compression)
|
||||||
|
* to 8 (slowest, most compression).
|
||||||
|
*/
|
||||||
|
public static readonly KEY_FLAC_COMPRESSION_LEVEL: string = 'flac-compression-level';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the encoding complexity.
|
||||||
|
* The associated value is an integer. These values are device and codec specific,
|
||||||
|
* but lower values generally result in faster and/or less power-hungry encoding.
|
||||||
|
*
|
||||||
|
* @see MediaCodecInfo.EncoderCapabilities#getComplexityRange()
|
||||||
|
*/
|
||||||
|
public static readonly KEY_COMPLEXITY: string = 'complexity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the desired encoding quality.
|
||||||
|
* The associated value is an integer. This key is only supported for encoders
|
||||||
|
* that are configured in constant-quality mode. These values are device and
|
||||||
|
* codec specific, but lower values generally result in more efficient
|
||||||
|
* (smaller-sized) encoding.
|
||||||
|
*
|
||||||
|
* @see MediaCodecInfo.EncoderCapabilities#getQualityRange()
|
||||||
|
*/
|
||||||
|
public static readonly KEY_QUALITY: string = 'quality';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the desired codec priority.
|
||||||
|
* <p>
|
||||||
|
* The associated value is an integer. Higher value means lower priority.
|
||||||
|
* <p>
|
||||||
|
* Currently, only two levels are supported:<br>
|
||||||
|
* 0: realtime priority - meaning that the codec shall support the given
|
||||||
|
* performance configuration (e.g. framerate) at realtime. This should
|
||||||
|
* only be used by media playback, capture, and possibly by realtime
|
||||||
|
* communication scenarios if best effort performance is not suitable.<br>
|
||||||
|
* 1: non-realtime priority (best effort).
|
||||||
|
* <p>
|
||||||
|
* This is a hint used at codec configuration and resource planning - to understand
|
||||||
|
* the realtime requirements of the application; however, due to the nature of
|
||||||
|
* media components, performance is not guaranteed.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static readonly KEY_PRIORITY: string = 'priority';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the desired operating frame rate for video or sample rate for audio
|
||||||
|
* that the codec will need to operate at.
|
||||||
|
* <p>
|
||||||
|
* The associated value is an integer or a float representing frames-per-second or
|
||||||
|
* samples-per-second
|
||||||
|
* <p>
|
||||||
|
* This is used for cases like high-speed/slow-motion video capture, where the video encoder
|
||||||
|
* format contains the target playback rate (e.g. 30fps), but the component must be able to
|
||||||
|
* handle the high operating capture rate (e.g. 240fps).
|
||||||
|
* <p>
|
||||||
|
* This rate will be used by codec for resource planning and setting the operating points.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static readonly KEY_OPERATING_RATE: string = 'operating-rate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the desired profile to be used by an encoder.
|
||||||
|
* The associated value is an integer.
|
||||||
|
* Constants are declared in {@link MediaCodecInfo.CodecProfileLevel}.
|
||||||
|
* This key is used as a hint, and is only supported for codecs
|
||||||
|
* that specify a profile. Note: Codecs are free to use all the available
|
||||||
|
* coding tools at the specified profile.
|
||||||
|
*
|
||||||
|
* @see MediaCodecInfo.CodecCapabilities#profileLevels
|
||||||
|
*/
|
||||||
|
public static readonly KEY_PROFILE: string = 'profile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the desired profile to be used by an encoder.
|
||||||
|
* The associated value is an integer.
|
||||||
|
* Constants are declared in {@link MediaCodecInfo.CodecProfileLevel}.
|
||||||
|
* This key is used as a further hint when specifying a desired profile,
|
||||||
|
* and is only supported for codecs that specify a level.
|
||||||
|
* <p>
|
||||||
|
* This key is ignored if the {@link #KEY_PROFILE profile} is not specified.
|
||||||
|
*
|
||||||
|
* @see MediaCodecInfo.CodecCapabilities#profileLevels
|
||||||
|
*/
|
||||||
|
public static readonly KEY_LEVEL: string = 'level';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional key describing the desired encoder latency in frames. This is an optional
|
||||||
|
* parameter that applies only to video encoders. If encoder supports it, it should ouput
|
||||||
|
* at least one output frame after being queued the specified number of frames. This key
|
||||||
|
* is ignored if the video encoder does not support the latency feature. Use the output
|
||||||
|
* format to verify that this feature was enabled and the actual value used by the encoder.
|
||||||
|
* <p>
|
||||||
|
* If the key is not specified, the default latency will be implenmentation specific.
|
||||||
|
* The associated value is an integer.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_LATENCY: string = 'latency';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional key describing the maximum number of non-display-order coded frames.
|
||||||
|
* This is an optional parameter that applies only to video encoders. Application should
|
||||||
|
* check the value for this key in the output format to see if codec will produce
|
||||||
|
* non-display-order coded frames. If encoder supports it, the output frames' order will be
|
||||||
|
* different from the display order and each frame's display order could be retrived from
|
||||||
|
* {@link MediaCodec.BufferInfo#presentationTimeUs}. Before API level 27, application may
|
||||||
|
* receive non-display-order coded frames even though the application did not request it.
|
||||||
|
* Note: Application should not rearrange the frames to display order before feeding them
|
||||||
|
* to {@link MediaMuxer#writeSampleData}.
|
||||||
|
* <p>
|
||||||
|
* The default value is 0.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_OUTPUT_REORDER_DEPTH: string = 'output-reorder-depth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the desired clockwise rotation on an output surface.
|
||||||
|
* This key is only used when the codec is configured using an output surface.
|
||||||
|
* The associated value is an integer, representing degrees. Supported values
|
||||||
|
* are 0, 90, 180 or 270. This is an optional field; if not specified, rotation
|
||||||
|
* defaults to 0.
|
||||||
|
*
|
||||||
|
* @see MediaCodecInfo.CodecCapabilities#profileLevels
|
||||||
|
*/
|
||||||
|
public static readonly KEY_ROTATION: string = 'rotation-degrees';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the desired bitrate mode to be used by an encoder.
|
||||||
|
* Constants are declared in {@link MediaCodecInfo.CodecCapabilities}.
|
||||||
|
*
|
||||||
|
* @see MediaCodecInfo.EncoderCapabilities#isBitrateModeSupported(int)
|
||||||
|
*/
|
||||||
|
public static readonly KEY_BITRATE_MODE: string = 'bitrate-mode';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the audio session ID of the AudioTrack associated
|
||||||
|
* to a tunneled video codec.
|
||||||
|
* The associated value is an integer.
|
||||||
|
*
|
||||||
|
* @see MediaCodecInfo.CodecCapabilities#FEATURE_TunneledPlayback
|
||||||
|
*/
|
||||||
|
public static readonly KEY_AUDIO_SESSION_ID: string = 'audio-session-id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key for boolean AUTOSELECT behavior for the track. Tracks with AUTOSELECT=true
|
||||||
|
* are considered when automatically selecting a track without specific user
|
||||||
|
* choice, based on the current locale.
|
||||||
|
* This is currently only used for subtitle tracks, when the user selected
|
||||||
|
* 'Default' for the captioning locale.
|
||||||
|
* The associated value is an integer, where non-0 means TRUE. This is an optional
|
||||||
|
* field; if not specified, AUTOSELECT defaults to TRUE.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_IS_AUTOSELECT: string = 'is-autoselect';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key for boolean DEFAULT behavior for the track. The track with DEFAULT=true is
|
||||||
|
* selected in the absence of a specific user choice.
|
||||||
|
* This is currently used in two scenarios:
|
||||||
|
* 1) for subtitle tracks, when the user selected 'Default' for the captioning locale.
|
||||||
|
* 2) for a {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track, indicating the image is the
|
||||||
|
* primary item in the file.
|
||||||
|
|
||||||
|
* The associated value is an integer, where non-0 means TRUE. This is an optional
|
||||||
|
* field; if not specified, DEFAULT is considered to be FALSE.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_IS_DEFAULT: string = 'is-default';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key for the FORCED field for subtitle tracks. True if it is a
|
||||||
|
* forced subtitle track. Forced subtitle tracks are essential for the
|
||||||
|
* content and are shown even when the user turns off Captions. They
|
||||||
|
* are used for example to translate foreign/alien dialogs or signs.
|
||||||
|
* The associated value is an integer, where non-0 means TRUE. This is an
|
||||||
|
* optional field; if not specified, FORCED defaults to FALSE.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_IS_FORCED_SUBTITLE: string = 'is-forced-subtitle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the number of haptic channels in an audio format.
|
||||||
|
* The associated value is an integer.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_HAPTIC_CHANNEL_COUNT: string = 'haptic-channel-count';
|
||||||
|
|
||||||
|
/** @hide */
|
||||||
|
public static readonly KEY_IS_TIMED_TEXT: string = 'is-timed-text';
|
||||||
|
|
||||||
|
// The following color aspect values must be in sync with the ones in HardwareAPI.h.
|
||||||
|
/**
|
||||||
|
* An optional key describing the color primaries, white point and
|
||||||
|
* luminance factors for video content.
|
||||||
|
*
|
||||||
|
* The associated value is an integer: 0 if unspecified, or one of the
|
||||||
|
* COLOR_STANDARD_ values.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_COLOR_STANDARD: string = 'color-standard';
|
||||||
|
|
||||||
|
/** BT.709 color chromacity coordinates with KR = 0.2126, KB = 0.0722. */
|
||||||
|
public static readonly COLOR_STANDARD_BT709: number = 1;
|
||||||
|
|
||||||
|
/** BT.601 625 color chromacity coordinates with KR = 0.299, KB = 0.114. */
|
||||||
|
public static readonly COLOR_STANDARD_BT601_PAL: number = 2;
|
||||||
|
|
||||||
|
/** BT.601 525 color chromacity coordinates with KR = 0.299, KB = 0.114. */
|
||||||
|
public static readonly COLOR_STANDARD_BT601_NTSC: number = 4;
|
||||||
|
|
||||||
|
/** BT.2020 color chromacity coordinates with KR = 0.2627, KB = 0.0593. */
|
||||||
|
public static readonly COLOR_STANDARD_BT2020: number = 6;
|
||||||
|
|
||||||
|
// /** @hide */
|
||||||
|
// @IntDef({
|
||||||
|
// COLOR_STANDARD_BT709,
|
||||||
|
// COLOR_STANDARD_BT601_PAL,
|
||||||
|
// COLOR_STANDARD_BT601_NTSC,
|
||||||
|
// COLOR_STANDARD_BT2020,
|
||||||
|
// })
|
||||||
|
// @Retention(RetentionPolicy.SOURCE)
|
||||||
|
// public @interface ColorStandard {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional key describing the opto-electronic transfer function used
|
||||||
|
* for the video content.
|
||||||
|
*
|
||||||
|
* The associated value is an integer: 0 if unspecified, or one of the
|
||||||
|
* COLOR_TRANSFER_ values.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_COLOR_TRANSFER: string = 'color-transfer';
|
||||||
|
|
||||||
|
/** Linear transfer characteristic curve. */
|
||||||
|
public static readonly COLOR_TRANSFER_LINEAR: number = 1;
|
||||||
|
|
||||||
|
/** SMPTE 170M transfer characteristic curve used by BT.601/BT.709/BT.2020. This is the curve
|
||||||
|
* used by most non-HDR video content. */
|
||||||
|
public static readonly COLOR_TRANSFER_SDR_VIDEO: number = 3;
|
||||||
|
|
||||||
|
/** SMPTE ST 2084 transfer function. This is used by some HDR video content. */
|
||||||
|
public static readonly COLOR_TRANSFER_ST2084: number = 6;
|
||||||
|
|
||||||
|
/** ARIB STD-B67 hybrid-log-gamma transfer function. This is used by some HDR video content. */
|
||||||
|
public static readonly COLOR_TRANSFER_HLG: number = 7;
|
||||||
|
|
||||||
|
/** @hide */
|
||||||
|
// @IntDef({
|
||||||
|
// COLOR_TRANSFER_LINEAR,
|
||||||
|
// COLOR_TRANSFER_SDR_VIDEO,
|
||||||
|
// COLOR_TRANSFER_ST2084,
|
||||||
|
// COLOR_TRANSFER_HLG,
|
||||||
|
// })
|
||||||
|
// @Retention(RetentionPolicy.SOURCE)
|
||||||
|
// public @interface ColorTransfer {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional key describing the range of the component values of the video content.
|
||||||
|
*
|
||||||
|
* The associated value is an integer: 0 if unspecified, or one of the
|
||||||
|
* COLOR_RANGE_ values.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_COLOR_RANGE: string = 'color-range';
|
||||||
|
|
||||||
|
/** Limited range. Y component values range from 16 to 235 for 8-bit content.
|
||||||
|
* Cr, Cy values range from 16 to 240 for 8-bit content.
|
||||||
|
* This is the default for video content. */
|
||||||
|
public static readonly COLOR_RANGE_LIMITED: number = 2;
|
||||||
|
|
||||||
|
/** Full range. Y, Cr and Cb component values range from 0 to 255 for 8-bit content. */
|
||||||
|
public static readonly COLOR_RANGE_FULL: number = 1;
|
||||||
|
|
||||||
|
// /** @hide */
|
||||||
|
// @IntDef({
|
||||||
|
// COLOR_RANGE_LIMITED,
|
||||||
|
// COLOR_RANGE_FULL,
|
||||||
|
// })
|
||||||
|
// @Retention(RetentionPolicy.SOURCE)
|
||||||
|
// public @interface ColorRange {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional key describing the static metadata of HDR (high-dynamic-range) video content.
|
||||||
|
*
|
||||||
|
* The associated value is a ByteBuffer. This buffer contains the raw contents of the
|
||||||
|
* Static Metadata Descriptor (including the descriptor ID) of an HDMI Dynamic Range and
|
||||||
|
* Mastering InfoFrame as defined by CTA-861.3. This key must be provided to video decoders
|
||||||
|
* for HDR video content unless this information is contained in the bitstream and the video
|
||||||
|
* decoder supports an HDR-capable profile. This key must be provided to video encoders for
|
||||||
|
* HDR video content.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_HDR_STATIC_INFO: string = 'hdr-static-info';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional key describing the HDR10+ metadata of the video content.
|
||||||
|
*
|
||||||
|
* The associated value is a ByteBuffer containing HDR10+ metadata conforming to the
|
||||||
|
* user_data_registered_itu_t_t35() syntax of SEI message for ST 2094-40. This key will
|
||||||
|
* be present on:
|
||||||
|
*<p>
|
||||||
|
* - The formats of output buffers of a decoder configured for HDR10+ profiles (such as
|
||||||
|
* {@link MediaCodecInfo.CodecProfileLevel#VP9Profile2HDR10Plus}, {@link
|
||||||
|
* MediaCodecInfo.CodecProfileLevel#VP9Profile3HDR10Plus} or {@link
|
||||||
|
* MediaCodecInfo.CodecProfileLevel#HEVCProfileMain10HDR10Plus}), or
|
||||||
|
*<p>
|
||||||
|
* - The formats of output buffers of an encoder configured for an HDR10+ profiles that
|
||||||
|
* uses out-of-band metadata (such as {@link
|
||||||
|
* MediaCodecInfo.CodecProfileLevel#VP9Profile2HDR10Plus} or {@link
|
||||||
|
* MediaCodecInfo.CodecProfileLevel#VP9Profile3HDR10Plus}).
|
||||||
|
*
|
||||||
|
* @see MediaCodec#PARAMETER_KEY_HDR10_PLUS_INFO
|
||||||
|
*/
|
||||||
|
public static readonly KEY_HDR10_PLUS_INFO: string = 'hdr10-plus-info';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing a unique ID for the content of a media track.
|
||||||
|
*
|
||||||
|
* <p>This key is used by {@link MediaExtractor}. Some extractors provide multiple encodings
|
||||||
|
* of the same track (e.g. float audio tracks for FLAC and WAV may be expressed as two
|
||||||
|
* tracks via MediaExtractor: a normal PCM track for backward compatibility, and a float PCM
|
||||||
|
* track for added fidelity. Similarly, Dolby Vision extractor may provide a baseline SDR
|
||||||
|
* version of a DV track.) This key can be used to identify which MediaExtractor tracks refer
|
||||||
|
* to the same underlying content.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* The associated value is an integer.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_TRACK_ID: string = 'track-id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the system id of the conditional access system used to scramble
|
||||||
|
* a media track.
|
||||||
|
* <p>
|
||||||
|
* This key is set by {@link MediaExtractor} if the track is scrambled with a conditional
|
||||||
|
* access system, regardless of the presence of a valid {@link MediaCas} object.
|
||||||
|
* <p>
|
||||||
|
* The associated value is an integer.
|
||||||
|
* @hide
|
||||||
|
*/
|
||||||
|
public static readonly KEY_CA_SYSTEM_ID: string = 'ca-system-id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the {@link MediaCas.Session} object associated with a media track.
|
||||||
|
* <p>
|
||||||
|
* This key is set by {@link MediaExtractor} if the track is scrambled with a conditional
|
||||||
|
* access system, after it receives a valid {@link MediaCas} object.
|
||||||
|
* <p>
|
||||||
|
* The associated value is a ByteBuffer.
|
||||||
|
* @hide
|
||||||
|
*/
|
||||||
|
public static readonly KEY_CA_SESSION_ID: string = 'ca-session-id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the private data in the CA_descriptor associated with a media track.
|
||||||
|
* <p>
|
||||||
|
* This key is set by {@link MediaExtractor} if the track is scrambled with a conditional
|
||||||
|
* access system, before it receives a valid {@link MediaCas} object.
|
||||||
|
* <p>
|
||||||
|
* The associated value is a ByteBuffer.
|
||||||
|
* @hide
|
||||||
|
*/
|
||||||
|
public static readonly KEY_CA_PRIVATE_DATA: string = 'ca-private-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key describing the maximum number of B frames between I or P frames,
|
||||||
|
* to be used by a video encoder.
|
||||||
|
* The associated value is an integer. The default value is 0, which means
|
||||||
|
* that no B frames are allowed. Note that non-zero value does not guarantee
|
||||||
|
* B frames; it's up to the encoder to decide.
|
||||||
|
*/
|
||||||
|
public static readonly KEY_MAX_B_FRAMES: string = 'max-bframes';
|
||||||
|
}
|
||||||
697
src/app/googDevice/client/ConfigureScrcpy.ts
Normal file
697
src/app/googDevice/client/ConfigureScrcpy.ts
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
import '../../../style/dialog.css';
|
||||||
|
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
|
||||||
|
import { DisplayCombinedInfo } from '../../client/StreamReceiver';
|
||||||
|
import VideoSettings from '../../VideoSettings';
|
||||||
|
import { StreamClientScrcpy } from './StreamClientScrcpy';
|
||||||
|
import Size from '../../Size';
|
||||||
|
import Util from '../../Util';
|
||||||
|
import { DisplayInfo } from '../../DisplayInfo';
|
||||||
|
import { ToolBoxButton } from '../../toolbox/ToolBoxButton';
|
||||||
|
import SvgImage from '../../ui/SvgImage';
|
||||||
|
import { PlayerClass } from '../../player/BasePlayer';
|
||||||
|
import { ToolBoxCheckbox } from '../../toolbox/ToolBoxCheckbox';
|
||||||
|
import { DeviceTracker } from './DeviceTracker';
|
||||||
|
import { Attribute } from '../../Attribute';
|
||||||
|
import { StreamReceiverScrcpy } from './StreamReceiverScrcpy';
|
||||||
|
import { ParamsStreamScrcpy } from '../../../types/ParamsStreamScrcpy';
|
||||||
|
import { BaseClient } from '../../client/BaseClient';
|
||||||
|
|
||||||
|
interface ConfigureScrcpyEvents {
|
||||||
|
closed: { dialog: ConfigureScrcpy; result: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Range = {
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
step: number;
|
||||||
|
formatter?: (value: number) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ConfigureScrcpy extends BaseClient<ParamsStreamScrcpy, ConfigureScrcpyEvents> {
|
||||||
|
private readonly TAG: string;
|
||||||
|
private readonly udid: string;
|
||||||
|
private readonly escapedUdid: string;
|
||||||
|
private readonly playerStorageKey: string;
|
||||||
|
private deviceName: string;
|
||||||
|
private streamReceiver?: StreamReceiverScrcpy;
|
||||||
|
private playerName?: string;
|
||||||
|
private displayInfo?: DisplayInfo;
|
||||||
|
private background: HTMLElement;
|
||||||
|
private dialogBody?: HTMLElement;
|
||||||
|
private okButton?: HTMLButtonElement;
|
||||||
|
private fitToScreenCheckbox?: HTMLInputElement;
|
||||||
|
private resetSettingsButton?: HTMLButtonElement;
|
||||||
|
private loadSettingsButton?: HTMLButtonElement;
|
||||||
|
private saveSettingsButton?: HTMLButtonElement;
|
||||||
|
private playerSelectElement?: HTMLSelectElement;
|
||||||
|
private displayIdSelectElement?: HTMLSelectElement;
|
||||||
|
private encoderSelectElement?: HTMLSelectElement;
|
||||||
|
private connectionStatusElement?: HTMLElement;
|
||||||
|
private dialogContainer?: HTMLElement;
|
||||||
|
private statusText = '';
|
||||||
|
private connectionCount = 0;
|
||||||
|
|
||||||
|
constructor(private readonly tracker: DeviceTracker, descriptor: GoogDeviceDescriptor, params: ParamsStreamScrcpy) {
|
||||||
|
super(params);
|
||||||
|
this.udid = descriptor.udid;
|
||||||
|
this.escapedUdid = Util.escapeUdid(this.udid);
|
||||||
|
this.playerStorageKey = `configure_stream::${this.escapedUdid}::player`;
|
||||||
|
this.deviceName = descriptor['ro.product.model'];
|
||||||
|
this.TAG = `ConfigureScrcpy[${this.udid}]`;
|
||||||
|
this.createStreamReceiver(params);
|
||||||
|
this.setTitle(`${this.deviceName}. Configure stream`);
|
||||||
|
this.background = this.createUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTracker(): DeviceTracker {
|
||||||
|
return this.tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建流接收器
|
||||||
|
private createStreamReceiver(params: ParamsStreamScrcpy): void {
|
||||||
|
// 如果已经存在流接收器,则先解绑事件监听器,并停止流接收器
|
||||||
|
if (this.streamReceiver) {
|
||||||
|
this.detachEventsListeners(this.streamReceiver);
|
||||||
|
this.streamReceiver.stop();
|
||||||
|
}
|
||||||
|
// 创建新的流接收器
|
||||||
|
this.streamReceiver = new StreamReceiverScrcpy(params);
|
||||||
|
// 绑定事件监听器
|
||||||
|
this.attachEventsListeners(this.streamReceiver);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件监听器,用于监听StreamReceiverScrcpy对象的事件
|
||||||
|
private attachEventsListeners(streamReceiver: StreamReceiverScrcpy): void {
|
||||||
|
// 监听encoders事件,当有新的编码器连接时触发
|
||||||
|
streamReceiver.on('encoders', this.onEncoders);
|
||||||
|
// 监听displayInfo事件,当显示信息发生变化时触发
|
||||||
|
streamReceiver.on('displayInfo', this.onDisplayInfo);
|
||||||
|
// 监听connected事件,当连接成功时触发
|
||||||
|
streamReceiver.on('connected', this.onConnected);
|
||||||
|
// 监听disconnected事件,当连接断开时触发
|
||||||
|
streamReceiver.on('disconnected', this.onDisconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除事件监听器
|
||||||
|
private detachEventsListeners(streamReceiver: StreamReceiverScrcpy): void {
|
||||||
|
// 移除encoders事件监听器
|
||||||
|
streamReceiver.off('encoders', this.onEncoders);
|
||||||
|
// 移除displayInfo事件监听器
|
||||||
|
streamReceiver.off('displayInfo', this.onDisplayInfo);
|
||||||
|
// 移除connected事件监听器
|
||||||
|
streamReceiver.off('connected', this.onConnected);
|
||||||
|
// 移除disconnected事件监听器
|
||||||
|
streamReceiver.off('disconnected', this.onDisconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新连接状态
|
||||||
|
private updateStatus(): void {
|
||||||
|
// 如果没有连接状态元素,则返回
|
||||||
|
if (!this.connectionStatusElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取连接状态文本
|
||||||
|
let text = this.statusText;
|
||||||
|
// 如果有其他客户端连接,则添加其他客户端连接数
|
||||||
|
if (this.connectionCount) {
|
||||||
|
text = `${text}. Other clients: ${this.connectionCount}.`;
|
||||||
|
}
|
||||||
|
// 将连接状态文本设置到连接状态元素中
|
||||||
|
this.connectionStatusElement.innerText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEncoders = (encoders: string[]): void => {
|
||||||
|
// console.log(this.TAG, 'Encoders', encoders);
|
||||||
|
const select = this.encoderSelectElement || document.createElement('select');
|
||||||
|
let child;
|
||||||
|
while ((child = select.firstChild)) {
|
||||||
|
select.removeChild(child);
|
||||||
|
}
|
||||||
|
encoders.unshift('');
|
||||||
|
encoders.forEach((value) => {
|
||||||
|
const optionElement = document.createElement('option');
|
||||||
|
optionElement.setAttribute('value', value);
|
||||||
|
optionElement.innerText = value;
|
||||||
|
select.appendChild(optionElement);
|
||||||
|
});
|
||||||
|
this.encoderSelectElement = select;
|
||||||
|
};
|
||||||
|
|
||||||
|
private onDisplayInfo = (infoArray: DisplayCombinedInfo[]): void => {
|
||||||
|
// console.log(this.TAG, 'Received info');
|
||||||
|
this.statusText = 'Ready';
|
||||||
|
this.updateStatus();
|
||||||
|
this.dialogContainer?.classList.add('ready');
|
||||||
|
const select = this.displayIdSelectElement || document.createElement('select');
|
||||||
|
let child;
|
||||||
|
while ((child = select.firstChild)) {
|
||||||
|
select.removeChild(child);
|
||||||
|
}
|
||||||
|
let selectedOptionIdx = -1;
|
||||||
|
infoArray.forEach((value: DisplayCombinedInfo, idx: number) => {
|
||||||
|
const { displayInfo } = value;
|
||||||
|
const { displayId, size } = displayInfo;
|
||||||
|
const optionElement = document.createElement('option');
|
||||||
|
optionElement.setAttribute('value', displayId.toString());
|
||||||
|
optionElement.innerText = `ID: ${displayId}; ${size.width}x${size.height}`;
|
||||||
|
select.appendChild(optionElement);
|
||||||
|
if (
|
||||||
|
(this.displayInfo && this.displayInfo.displayId === displayId) ||
|
||||||
|
(!this.displayInfo && displayId === DisplayInfo.DEFAULT_DISPLAY)
|
||||||
|
) {
|
||||||
|
selectedOptionIdx = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (selectedOptionIdx > -1) {
|
||||||
|
select.selectedIndex = selectedOptionIdx;
|
||||||
|
const { videoSettings, connectionCount, displayInfo } = infoArray[selectedOptionIdx];
|
||||||
|
this.displayInfo = displayInfo;
|
||||||
|
if (connectionCount > 0 && videoSettings) {
|
||||||
|
// console.log(this.TAG, 'Apply other clients settings');
|
||||||
|
this.fillInputsFromVideoSettings(videoSettings, false);
|
||||||
|
} else {
|
||||||
|
// console.log(this.TAG, 'Apply settings for current player');
|
||||||
|
this.updateVideoSettingsForPlayer();
|
||||||
|
}
|
||||||
|
this.connectionCount = connectionCount;
|
||||||
|
this.updateStatus();
|
||||||
|
}
|
||||||
|
this.displayIdSelectElement = select;
|
||||||
|
if (this.dialogBody) {
|
||||||
|
this.dialogBody.classList.remove('hidden');
|
||||||
|
this.dialogBody.classList.add('visible');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onConnected = (): void => {
|
||||||
|
// console.log(this.TAG, 'Connected');
|
||||||
|
this.statusText = 'Waiting for info...';
|
||||||
|
this.updateStatus();
|
||||||
|
if (this.okButton) {
|
||||||
|
this.okButton.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onDisconnected = (): void => {
|
||||||
|
// console.log(this.TAG, 'Disconnected');
|
||||||
|
this.statusText = 'Disconnected';
|
||||||
|
this.updateStatus();
|
||||||
|
if (this.okButton) {
|
||||||
|
this.okButton.disabled = true;
|
||||||
|
}
|
||||||
|
if (this.dialogBody) {
|
||||||
|
this.dialogBody.classList.remove('visible');
|
||||||
|
this.dialogBody.classList.add('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPlayerChange = (): void => {
|
||||||
|
this.updateVideoSettingsForPlayer();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onDisplayIdChange = (): void => {
|
||||||
|
const select = this.displayIdSelectElement;
|
||||||
|
if (!select || !this.streamReceiver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = select.options[select.selectedIndex].value;
|
||||||
|
const displayId = parseInt(value, 10);
|
||||||
|
if (!isNaN(displayId)) {
|
||||||
|
this.displayInfo = this.streamReceiver.getDisplayInfo(displayId);
|
||||||
|
}
|
||||||
|
this.updateVideoSettingsForPlayer();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取玩家
|
||||||
|
private getPlayer(): PlayerClass | undefined {
|
||||||
|
// 如果没有选择玩家,则返回undefined
|
||||||
|
if (!this.playerSelectElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取选择的玩家名称
|
||||||
|
const playerName = this.playerSelectElement.options[this.playerSelectElement.selectedIndex].value;
|
||||||
|
// 在StreamClientScrcpy中查找玩家
|
||||||
|
return StreamClientScrcpy.getPlayers().find((playerClass) => {
|
||||||
|
// 如果玩家名称匹配,则返回该玩家
|
||||||
|
return playerClass.playerFullName === playerName;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新播放器的视频设置
|
||||||
|
private updateVideoSettingsForPlayer(): void {
|
||||||
|
// 获取播放器
|
||||||
|
const player = this.getPlayer();
|
||||||
|
// 如果播放器存在
|
||||||
|
if (player) {
|
||||||
|
// 设置播放器名称
|
||||||
|
this.playerName = player.playerFullName;
|
||||||
|
// 获取存储或首选的视频设置
|
||||||
|
const storedOrPreferred = player.loadVideoSettings(this.udid, this.displayInfo);
|
||||||
|
// 获取屏幕适应状态
|
||||||
|
const fitToScreen = player.getFitToScreenStatus(this.udid, this.displayInfo);
|
||||||
|
// 从视频设置中填充输入
|
||||||
|
this.fillInputsFromVideoSettings(storedOrPreferred, fitToScreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据id获取基本的输入元素
|
||||||
|
private getBasicInput(id: string): HTMLInputElement | null {
|
||||||
|
// 根据id和udid获取元素
|
||||||
|
const element = document.getElementById(`${id}_${this.escapedUdid}`);
|
||||||
|
// 如果元素不存在,返回null
|
||||||
|
if (!element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 返回元素
|
||||||
|
return element as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据视频设置填充输入框
|
||||||
|
private fillInputsFromVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean): void {
|
||||||
|
// 如果显示信息和视频设置中的显示id不匹配,则输出错误信息
|
||||||
|
if (this.displayInfo && this.displayInfo.displayId !== videoSettings.displayId) {
|
||||||
|
console.error(this.TAG, `Display id from VideoSettings and DisplayInfo don't match`);
|
||||||
|
}
|
||||||
|
// 根据视频设置填充基本输入框
|
||||||
|
this.fillBasicInput({ id: 'bitrate' }, videoSettings);
|
||||||
|
this.fillBasicInput({ id: 'maxFps' }, videoSettings);
|
||||||
|
this.fillBasicInput({ id: 'iFrameInterval' }, videoSettings);
|
||||||
|
// this.fillBasicInput({ id: 'displayId' }, videoSettings);
|
||||||
|
this.fillBasicInput({ id: 'codecOptions' }, videoSettings);
|
||||||
|
// 如果视频设置中有边界,则填充最大宽度和最大高度输入框
|
||||||
|
if (videoSettings.bounds) {
|
||||||
|
const { width, height } = videoSettings.bounds;
|
||||||
|
const widthInput = this.getBasicInput('maxWidth');
|
||||||
|
if (widthInput) {
|
||||||
|
widthInput.value = width.toString(10);
|
||||||
|
}
|
||||||
|
const heightInput = this.getBasicInput('maxHeight');
|
||||||
|
if (heightInput) {
|
||||||
|
heightInput.value = height.toString(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果有编码器选择框,则根据视频设置填充编码器选择框
|
||||||
|
if (this.encoderSelectElement) {
|
||||||
|
const encoderName = videoSettings.encoderName || '';
|
||||||
|
const option = Array.from(this.encoderSelectElement.options).find((element) => {
|
||||||
|
return element.value === encoderName;
|
||||||
|
});
|
||||||
|
if (option) {
|
||||||
|
this.encoderSelectElement.selectedIndex = option.index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果有适应屏幕复选框,则根据fitToScreen参数填充复选框,并调用onFitToScreenChanged方法
|
||||||
|
if (this.fitToScreenCheckbox) {
|
||||||
|
this.fitToScreenCheckbox.checked = fitToScreen;
|
||||||
|
this.onFitToScreenChanged(fitToScreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当fitToScreenCheckbox的值改变时调用
|
||||||
|
private onFitToScreenChanged(checked: boolean) {
|
||||||
|
// 获取maxHeight和maxWidth的输入框
|
||||||
|
const heightInput = this.getBasicInput('maxHeight');
|
||||||
|
const widthInput = this.getBasicInput('maxWidth');
|
||||||
|
// 如果fitToScreenCheckbox、heightInput或widthInput不存在,则返回
|
||||||
|
if (!this.fitToScreenCheckbox || !heightInput || !widthInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果checked为true,则禁用heightInput和widthInput
|
||||||
|
heightInput.disabled = widthInput.disabled = checked;
|
||||||
|
// 如果checked为true,则将heightInput和widthInput的值清空,并保存原来的值
|
||||||
|
if (checked) {
|
||||||
|
heightInput.setAttribute(Attribute.VALUE, heightInput.value);
|
||||||
|
heightInput.value = '';
|
||||||
|
widthInput.setAttribute(Attribute.VALUE, widthInput.value);
|
||||||
|
widthInput.value = '';
|
||||||
|
} else {
|
||||||
|
// 如果checked为false,则将heightInput和widthInput的值恢复为原来的值
|
||||||
|
const storedHeight = heightInput.getAttribute(Attribute.VALUE);
|
||||||
|
if (typeof storedHeight === 'string') {
|
||||||
|
heightInput.value = storedHeight;
|
||||||
|
heightInput.removeAttribute(Attribute.VALUE);
|
||||||
|
}
|
||||||
|
const storedWidth = widthInput.getAttribute(Attribute.VALUE);
|
||||||
|
if (typeof storedWidth === 'string') {
|
||||||
|
widthInput.value = storedWidth;
|
||||||
|
widthInput.removeAttribute(Attribute.VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据传入的参数,填充视频设置的基本输入框
|
||||||
|
private fillBasicInput(opts: { id: keyof VideoSettings }, videoSettings: VideoSettings): void {
|
||||||
|
// 获取基本输入框
|
||||||
|
const input = this.getBasicInput(opts.id);
|
||||||
|
// 获取视频设置中对应id的值
|
||||||
|
const value = videoSettings[opts.id];
|
||||||
|
// 如果输入框存在
|
||||||
|
if (input) {
|
||||||
|
// 如果值存在且不为'-'、0、null
|
||||||
|
if (typeof value !== 'undefined' && value !== '-' && value !== 0 && value !== null) {
|
||||||
|
// 将值转换为字符串并赋值给输入框
|
||||||
|
input.value = value.toString(10);
|
||||||
|
// 如果输入框的类型为range,触发input事件
|
||||||
|
if (input.getAttribute('type') === 'range') {
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 否则将输入框的值置为空
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendBasicInput(
|
||||||
|
parent: HTMLElement,
|
||||||
|
opts: { label: string; id: string; range?: Range },
|
||||||
|
): HTMLInputElement {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.classList.add('label');
|
||||||
|
label.innerText = `${opts.label}:`;
|
||||||
|
label.id = `label_${opts.id}_${this.escapedUdid}`;
|
||||||
|
parent.appendChild(label);
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.classList.add('input');
|
||||||
|
input.id = label.htmlFor = `${opts.id}_${this.escapedUdid}`;
|
||||||
|
const { range } = opts;
|
||||||
|
if (range) {
|
||||||
|
label.setAttribute('title', opts.label);
|
||||||
|
input.oninput = () => {
|
||||||
|
const value = range.formatter ? range.formatter(parseInt(input.value, 10)) : input.value;
|
||||||
|
label.innerText = `${opts.label} (${value}):`;
|
||||||
|
};
|
||||||
|
input.setAttribute('type', 'range');
|
||||||
|
input.setAttribute('max', range.max.toString());
|
||||||
|
input.setAttribute('min', range.min.toString());
|
||||||
|
input.setAttribute('step', range.step.toString());
|
||||||
|
}
|
||||||
|
parent.appendChild(input);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNumberValueFromInput(name: string): number {
|
||||||
|
const value = (document.getElementById(`${name}_${this.escapedUdid}`) as HTMLInputElement).value;
|
||||||
|
return parseInt(value, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStringValueFromInput(name: string): string {
|
||||||
|
return (document.getElementById(`${name}_${this.escapedUdid}`) as HTMLInputElement).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getValueFromSelect(name: string): string {
|
||||||
|
const select = document.getElementById(`${name}_${this.escapedUdid}`) as HTMLSelectElement;
|
||||||
|
return select.options[select.selectedIndex].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildVideoSettings(): VideoSettings | null {
|
||||||
|
try {
|
||||||
|
const bitrate = this.getNumberValueFromInput('bitrate');
|
||||||
|
const maxFps = this.getNumberValueFromInput('maxFps');
|
||||||
|
const iFrameInterval = this.getNumberValueFromInput('iFrameInterval');
|
||||||
|
const maxWidth = this.getNumberValueFromInput('maxWidth');
|
||||||
|
const maxHeight = this.getNumberValueFromInput('maxHeight');
|
||||||
|
const displayId = this.getNumberValueFromInput('displayId');
|
||||||
|
const codecOptions = this.getStringValueFromInput('codecOptions') || undefined;
|
||||||
|
let bounds: Size | undefined;
|
||||||
|
if (!isNaN(maxWidth) && !isNaN(maxHeight) && maxWidth && maxHeight) {
|
||||||
|
bounds = new Size(maxWidth, maxHeight);
|
||||||
|
}
|
||||||
|
const encoderName = this.getValueFromSelect('encoderName') || undefined;
|
||||||
|
return new VideoSettings({
|
||||||
|
bitrate,
|
||||||
|
bounds,
|
||||||
|
maxFps,
|
||||||
|
iFrameInterval,
|
||||||
|
displayId,
|
||||||
|
codecOptions,
|
||||||
|
encoderName,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(this.TAG, error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFitToScreenValue(): boolean {
|
||||||
|
if (!this.fitToScreenCheckbox) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.fitToScreenCheckbox.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPreviouslyUsedPlayer(): string {
|
||||||
|
if (!window.localStorage) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const result = window.localStorage.getItem(this.playerStorageKey);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPreviouslyUsedPlayer(playerName: string): void {
|
||||||
|
if (!window.localStorage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(this.playerStorageKey, playerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createUI(): HTMLElement {
|
||||||
|
const dialogName = 'configureDialog';
|
||||||
|
const blockClass = 'dialog-block';
|
||||||
|
const background = document.createElement('div');
|
||||||
|
background.classList.add('dialog-background', dialogName);
|
||||||
|
const dialogContainer = (this.dialogContainer = document.createElement('div'));
|
||||||
|
dialogContainer.classList.add('dialog-container', dialogName);
|
||||||
|
const dialogHeader = document.createElement('div');
|
||||||
|
dialogHeader.classList.add('dialog-header', dialogName, 'control-wrapper');
|
||||||
|
const backButton = new ToolBoxButton('Back', SvgImage.Icon.ARROW_BACK);
|
||||||
|
|
||||||
|
backButton.addEventListener('click', () => {
|
||||||
|
this.cancel();
|
||||||
|
});
|
||||||
|
backButton.getAllElements().forEach((el) => {
|
||||||
|
dialogHeader.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceName = document.createElement('span');
|
||||||
|
deviceName.classList.add('dialog-title', 'main-title');
|
||||||
|
deviceName.innerText = this.deviceName;
|
||||||
|
dialogHeader.appendChild(deviceName);
|
||||||
|
const dialogBody = (this.dialogBody = document.createElement('div'));
|
||||||
|
dialogBody.classList.add('dialog-body', blockClass, dialogName, 'hidden');
|
||||||
|
const playerWrapper = document.createElement('div');
|
||||||
|
playerWrapper.classList.add('controls');
|
||||||
|
const playerLabel = document.createElement('label');
|
||||||
|
playerLabel.classList.add('label');
|
||||||
|
playerLabel.innerText = 'Player:';
|
||||||
|
playerWrapper.appendChild(playerLabel);
|
||||||
|
const playerSelect = (this.playerSelectElement = document.createElement('select'));
|
||||||
|
playerSelect.classList.add('input');
|
||||||
|
playerSelect.id = playerLabel.htmlFor = `player_${this.escapedUdid}`;
|
||||||
|
playerWrapper.appendChild(playerSelect);
|
||||||
|
dialogBody.appendChild(playerWrapper);
|
||||||
|
const previouslyUsedPlayer = this.getPreviouslyUsedPlayer();
|
||||||
|
StreamClientScrcpy.getPlayers().forEach((playerClass, index) => {
|
||||||
|
const { playerFullName } = playerClass;
|
||||||
|
const optionElement = document.createElement('option');
|
||||||
|
optionElement.setAttribute('value', playerFullName);
|
||||||
|
optionElement.innerText = playerFullName;
|
||||||
|
playerSelect.appendChild(optionElement);
|
||||||
|
if (playerFullName === previouslyUsedPlayer) {
|
||||||
|
playerSelect.selectedIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
playerSelect.onchange = this.onPlayerChange;
|
||||||
|
this.updateVideoSettingsForPlayer();
|
||||||
|
|
||||||
|
const controls = document.createElement('div');
|
||||||
|
controls.classList.add('controls', 'control-wrapper');
|
||||||
|
const displayIdLabel = document.createElement('label');
|
||||||
|
displayIdLabel.classList.add('label');
|
||||||
|
displayIdLabel.innerText = 'Display:';
|
||||||
|
controls.appendChild(displayIdLabel);
|
||||||
|
if (!this.displayIdSelectElement) {
|
||||||
|
this.displayIdSelectElement = document.createElement('select');
|
||||||
|
}
|
||||||
|
controls.appendChild(this.displayIdSelectElement);
|
||||||
|
this.displayIdSelectElement.classList.add('input');
|
||||||
|
this.displayIdSelectElement.id = displayIdLabel.htmlFor = `displayId_${this.escapedUdid}`;
|
||||||
|
this.displayIdSelectElement.onchange = this.onDisplayIdChange;
|
||||||
|
|
||||||
|
this.appendBasicInput(controls, {
|
||||||
|
label: 'Bitrate',
|
||||||
|
id: 'bitrate',
|
||||||
|
range: { min: 524288, max: 8388608, step: 524288, formatter: Util.prettyBytes },
|
||||||
|
});
|
||||||
|
this.appendBasicInput(controls, {
|
||||||
|
label: 'Max FPS',
|
||||||
|
id: 'maxFps',
|
||||||
|
range: { min: 1, max: 60, step: 1 },
|
||||||
|
});
|
||||||
|
this.appendBasicInput(controls, { label: 'I-Frame interval', id: 'iFrameInterval' });
|
||||||
|
const fitLabel = document.createElement('label');
|
||||||
|
fitLabel.innerText = 'Fit to screen';
|
||||||
|
fitLabel.classList.add('label');
|
||||||
|
controls.appendChild(fitLabel);
|
||||||
|
const fitToggle = new ToolBoxCheckbox(
|
||||||
|
'Fit to screen',
|
||||||
|
{ off: SvgImage.Icon.TOGGLE_OFF, on: SvgImage.Icon.TOGGLE_ON },
|
||||||
|
'fit_to_screen',
|
||||||
|
);
|
||||||
|
fitToggle.getAllElements().forEach((el) => {
|
||||||
|
controls.appendChild(el);
|
||||||
|
if (el instanceof HTMLLabelElement) {
|
||||||
|
fitLabel.htmlFor = el.htmlFor;
|
||||||
|
el.classList.add('input');
|
||||||
|
}
|
||||||
|
if (el instanceof HTMLInputElement) {
|
||||||
|
this.fitToScreenCheckbox = el;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fitToggle.addEventListener('click', (_, el) => {
|
||||||
|
const element = el.getElement();
|
||||||
|
this.onFitToScreenChanged(element.checked);
|
||||||
|
});
|
||||||
|
this.appendBasicInput(controls, { label: 'Max width', id: 'maxWidth' });
|
||||||
|
this.appendBasicInput(controls, { label: 'Max height', id: 'maxHeight' });
|
||||||
|
this.appendBasicInput(controls, { label: 'Codec options', id: 'codecOptions' });
|
||||||
|
|
||||||
|
const encoderLabel = document.createElement('label');
|
||||||
|
encoderLabel.classList.add('label');
|
||||||
|
encoderLabel.innerText = 'Encoder:';
|
||||||
|
controls.appendChild(encoderLabel);
|
||||||
|
if (!this.encoderSelectElement) {
|
||||||
|
this.encoderSelectElement = document.createElement('select');
|
||||||
|
}
|
||||||
|
controls.appendChild(this.encoderSelectElement);
|
||||||
|
this.encoderSelectElement.classList.add('input');
|
||||||
|
this.encoderSelectElement.id = encoderLabel.htmlFor = `encoderName_${this.escapedUdid}`;
|
||||||
|
|
||||||
|
dialogBody.appendChild(controls);
|
||||||
|
|
||||||
|
const buttonsWrapper = document.createElement('div');
|
||||||
|
buttonsWrapper.classList.add('controls');
|
||||||
|
|
||||||
|
const resetSettingsButton = (this.resetSettingsButton = document.createElement('button'));
|
||||||
|
resetSettingsButton.classList.add('button');
|
||||||
|
resetSettingsButton.innerText = 'Reset settings';
|
||||||
|
resetSettingsButton.addEventListener('click', this.resetSettings);
|
||||||
|
buttonsWrapper.appendChild(resetSettingsButton);
|
||||||
|
|
||||||
|
const loadSettingsButton = (this.loadSettingsButton = document.createElement('button'));
|
||||||
|
loadSettingsButton.classList.add('button');
|
||||||
|
loadSettingsButton.innerText = 'Load settings';
|
||||||
|
loadSettingsButton.addEventListener('click', this.loadSettings);
|
||||||
|
buttonsWrapper.appendChild(loadSettingsButton);
|
||||||
|
|
||||||
|
const saveSettingsButton = (this.saveSettingsButton = document.createElement('button'));
|
||||||
|
saveSettingsButton.classList.add('button');
|
||||||
|
saveSettingsButton.innerText = 'Save settings';
|
||||||
|
saveSettingsButton.addEventListener('click', this.saveSettings);
|
||||||
|
buttonsWrapper.appendChild(saveSettingsButton);
|
||||||
|
|
||||||
|
dialogBody.appendChild(buttonsWrapper);
|
||||||
|
|
||||||
|
const dialogFooter = document.createElement('div');
|
||||||
|
dialogFooter.classList.add('dialog-footer', blockClass, dialogName);
|
||||||
|
const statusElement = document.createElement('span');
|
||||||
|
statusElement.classList.add('subtitle');
|
||||||
|
this.connectionStatusElement = statusElement;
|
||||||
|
dialogFooter.appendChild(statusElement);
|
||||||
|
this.statusText = `Connecting...`;
|
||||||
|
this.updateStatus();
|
||||||
|
|
||||||
|
// const cancelButton = (this.cancelButton = document.createElement('button'));
|
||||||
|
// cancelButton.innerText = 'Cancel';
|
||||||
|
// cancelButton.addEventListener('click', this.cancel);
|
||||||
|
const okButton = (this.okButton = document.createElement('button'));
|
||||||
|
okButton.innerText = 'Open';
|
||||||
|
okButton.disabled = true;
|
||||||
|
okButton.addEventListener('click', this.openStream);
|
||||||
|
dialogFooter.appendChild(okButton);
|
||||||
|
// dialogFooter.appendChild(cancelButton);
|
||||||
|
dialogBody.appendChild(dialogFooter);
|
||||||
|
dialogContainer.appendChild(dialogHeader);
|
||||||
|
dialogContainer.appendChild(dialogBody);
|
||||||
|
dialogContainer.appendChild(dialogFooter);
|
||||||
|
background.appendChild(dialogContainer);
|
||||||
|
background.addEventListener('click', this.onBackgroundClick);
|
||||||
|
document.body.appendChild(background);
|
||||||
|
return background;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeUI(): void {
|
||||||
|
document.body.removeChild(this.background);
|
||||||
|
this.okButton?.removeEventListener('click', this.openStream);
|
||||||
|
// this.cancelButton?.removeEventListener('click', this.cancel);
|
||||||
|
this.resetSettingsButton?.removeEventListener('click', this.resetSettings);
|
||||||
|
this.loadSettingsButton?.removeEventListener('click', this.loadSettings);
|
||||||
|
this.saveSettingsButton?.removeEventListener('click', this.saveSettings);
|
||||||
|
this.background.removeEventListener('click', this.onBackgroundClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onBackgroundClick = (event: MouseEvent): void => {
|
||||||
|
if (event.target !== event.currentTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
private cancel = (): void => {
|
||||||
|
if (this.streamReceiver) {
|
||||||
|
this.detachEventsListeners(this.streamReceiver);
|
||||||
|
this.streamReceiver.stop();
|
||||||
|
}
|
||||||
|
this.emit('closed', { dialog: this, result: false });
|
||||||
|
this.removeUI();
|
||||||
|
};
|
||||||
|
|
||||||
|
private resetSettings = (): void => {
|
||||||
|
const player = this.getPlayer();
|
||||||
|
if (player) {
|
||||||
|
this.fillInputsFromVideoSettings(player.getPreferredVideoSetting(), false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private loadSettings = (): void => {
|
||||||
|
this.updateVideoSettingsForPlayer();
|
||||||
|
};
|
||||||
|
|
||||||
|
private saveSettings = (): void => {
|
||||||
|
const videoSettings = this.buildVideoSettings();
|
||||||
|
const player = this.getPlayer();
|
||||||
|
if (videoSettings && player) {
|
||||||
|
const fitToScreen = this.getFitToScreenValue();
|
||||||
|
player.saveVideoSettings(this.udid, videoSettings, fitToScreen, this.displayInfo);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private openStream = (): void => {
|
||||||
|
const videoSettings = this.buildVideoSettings();
|
||||||
|
if (!videoSettings || !this.streamReceiver || !this.playerName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fitToScreen = this.getFitToScreenValue();
|
||||||
|
this.detachEventsListeners(this.streamReceiver);
|
||||||
|
this.emit('closed', { dialog: this, result: true });
|
||||||
|
this.removeUI();
|
||||||
|
const player = StreamClientScrcpy.createPlayer(this.playerName, this.udid, this.displayInfo);
|
||||||
|
if (!player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setPreviouslyUsedPlayer(this.playerName);
|
||||||
|
// return;
|
||||||
|
player.setVideoSettings(videoSettings, fitToScreen, false);
|
||||||
|
const params: ParamsStreamScrcpy = {
|
||||||
|
...this.params,
|
||||||
|
udid: this.udid,
|
||||||
|
fitToScreen,
|
||||||
|
};
|
||||||
|
StreamClientScrcpy.start(params, this.streamReceiver, player, fitToScreen, videoSettings);
|
||||||
|
this.streamReceiver.triggerInitialInfoEvents();
|
||||||
|
};
|
||||||
|
}
|
||||||
364
src/app/googDevice/client/DeviceTracker.ts
Normal file
364
src/app/googDevice/client/DeviceTracker.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
import '../../../style/devicelist.css';
|
||||||
|
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
|
||||||
|
import { SERVER_PORT } from '../../../common/Constants';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
|
||||||
|
import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
|
||||||
|
import { StreamClientScrcpy } from './StreamClientScrcpy';
|
||||||
|
import SvgImage from '../../ui/SvgImage';
|
||||||
|
import { html } from '../../ui/HtmlTag';
|
||||||
|
import Util from '../../Util';
|
||||||
|
import { Attribute } from '../../Attribute';
|
||||||
|
import { DeviceState } from '../../../common/DeviceState';
|
||||||
|
import { Message } from '../../../types/Message';
|
||||||
|
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
|
||||||
|
import { HostItem } from '../../../types/Configuration';
|
||||||
|
import { ChannelCode } from '../../../common/ChannelCode';
|
||||||
|
import { Tool } from '../../client/Tool';
|
||||||
|
|
||||||
|
type Field = keyof GoogDeviceDescriptor | ((descriptor: GoogDeviceDescriptor) => string);
|
||||||
|
type DescriptionColumn = { title: string; field: Field };
|
||||||
|
|
||||||
|
const DESC_COLUMNS: DescriptionColumn[] = [
|
||||||
|
{
|
||||||
|
title: 'Net Interface',
|
||||||
|
field: 'interfaces',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Server PID',
|
||||||
|
field: 'pid',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export class DeviceTracker extends BaseDeviceTracker<GoogDeviceDescriptor, never> {
|
||||||
|
public static readonly ACTION = ACTION.GOOG_DEVICE_LIST;
|
||||||
|
public static readonly CREATE_DIRECT_LINKS = true;
|
||||||
|
private static instancesByUrl: Map<string, DeviceTracker> = new Map();
|
||||||
|
protected static tools: Set<Tool> = new Set();
|
||||||
|
protected tableId = 'goog_device_list';
|
||||||
|
|
||||||
|
public static start(hostItem: HostItem): DeviceTracker {
|
||||||
|
const url = this.buildUrlForTracker(hostItem).toString();
|
||||||
|
let instance = this.instancesByUrl.get(url);
|
||||||
|
if (!instance) {
|
||||||
|
instance = new DeviceTracker(hostItem, url);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(hostItem: HostItem): DeviceTracker {
|
||||||
|
return this.start(hostItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected constructor(params: HostItem, directUrl: string) {
|
||||||
|
super({ ...params, action: DeviceTracker.ACTION }, directUrl);
|
||||||
|
DeviceTracker.instancesByUrl.set(directUrl, this);
|
||||||
|
this.buildDeviceTable();
|
||||||
|
this.openNewConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketOpen(): void {
|
||||||
|
// nothing here;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setIdAndHostName(id: string, hostName: string): void {
|
||||||
|
super.setIdAndHostName(id, hostName);
|
||||||
|
for (const value of DeviceTracker.instancesByUrl.values()) {
|
||||||
|
if (value.id === id && value !== this) {
|
||||||
|
console.warn(
|
||||||
|
`Tracker with url: "${this.url}" has the same id(${this.id}) as tracker with url "${value.url}"`,
|
||||||
|
);
|
||||||
|
console.warn(`This tracker will shut down`);
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onInterfaceSelected = (event: Event): void => {
|
||||||
|
const selectElement = event.currentTarget as HTMLSelectElement;
|
||||||
|
const option = selectElement.selectedOptions[0];
|
||||||
|
const url = decodeURI(option.getAttribute(Attribute.URL) || '');
|
||||||
|
const name = option.getAttribute(Attribute.NAME) || '';
|
||||||
|
const fullName = decodeURIComponent(selectElement.getAttribute(Attribute.FULL_NAME) || '');
|
||||||
|
const udid = selectElement.getAttribute(Attribute.UDID) || '';
|
||||||
|
this.updateLink({ url, name, fullName, udid, store: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateLink(params: { url: string; name: string; fullName: string; udid: string; store: boolean }): void {
|
||||||
|
const { url, name, fullName, udid, store } = params;
|
||||||
|
const playerTds = document.getElementsByName(
|
||||||
|
encodeURIComponent(`${DeviceTracker.AttributePrefixPlayerFor}${fullName}`),
|
||||||
|
);
|
||||||
|
if (typeof udid !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (store) {
|
||||||
|
const localStorageKey = DeviceTracker.getLocalStorageKey(fullName || '');
|
||||||
|
if (localStorage && name) {
|
||||||
|
localStorage.setItem(localStorageKey, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const action = ACTION.STREAM_SCRCPY;
|
||||||
|
playerTds.forEach((item) => {
|
||||||
|
item.innerHTML = '';
|
||||||
|
const playerFullName = item.getAttribute(DeviceTracker.AttributePlayerFullName);
|
||||||
|
const playerCodeName = item.getAttribute(DeviceTracker.AttributePlayerCodeName);
|
||||||
|
if (!playerFullName || !playerCodeName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const link = DeviceTracker.buildLink(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
udid,
|
||||||
|
player: decodeURIComponent(playerCodeName),
|
||||||
|
ws: url,
|
||||||
|
},
|
||||||
|
decodeURIComponent(playerFullName),
|
||||||
|
this.params,
|
||||||
|
);
|
||||||
|
item.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionButtonClick = (event: MouseEvent): void => {
|
||||||
|
const button = event.currentTarget as HTMLButtonElement;
|
||||||
|
const udid = button.getAttribute(Attribute.UDID);
|
||||||
|
const pidString = button.getAttribute(Attribute.PID) || '';
|
||||||
|
const command = button.getAttribute(Attribute.COMMAND) as string;
|
||||||
|
const pid = parseInt(pidString, 10);
|
||||||
|
const data: Message = {
|
||||||
|
id: this.getNextId(),
|
||||||
|
type: command,
|
||||||
|
data: {
|
||||||
|
udid: typeof udid === 'string' ? udid : undefined,
|
||||||
|
pid: isNaN(pid) ? undefined : pid,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
||||||
|
console.log('发送的参数3');
|
||||||
|
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static getLocalStorageKey(udid: string): string {
|
||||||
|
return `device_list::${udid}::interface`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static createUrl(params: ParamsDeviceTracker, udid = ''): URL {
|
||||||
|
const secure = !!params.secure;
|
||||||
|
const hostname = params.hostname || location.hostname;
|
||||||
|
const port = typeof params.port === 'number' ? params.port : secure ? 443 : 80;
|
||||||
|
const pathname = params.pathname || location.pathname;
|
||||||
|
const urlObject = this.buildUrl({ ...params, secure, hostname, port, pathname });
|
||||||
|
if (udid) {
|
||||||
|
urlObject.searchParams.set('action', ACTION.PROXY_ADB);
|
||||||
|
urlObject.searchParams.set('remote', `tcp:${SERVER_PORT.toString(10)}`);
|
||||||
|
urlObject.searchParams.set('udid', udid);
|
||||||
|
}
|
||||||
|
return urlObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static createInterfaceOption(name: string, url: string): HTMLOptionElement {
|
||||||
|
const optionElement = document.createElement('option');
|
||||||
|
optionElement.setAttribute(Attribute.URL, url);
|
||||||
|
optionElement.setAttribute(Attribute.NAME, name);
|
||||||
|
optionElement.innerText = `proxy over adb`;
|
||||||
|
return optionElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static titleToClassName(title: string): string {
|
||||||
|
return title.toLowerCase().replace(/\s/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildDeviceRow(tbody: Element, device: GoogDeviceDescriptor): void {
|
||||||
|
let selectedInterfaceUrl = '';
|
||||||
|
let selectedInterfaceName = '';
|
||||||
|
const blockClass = 'desc-block';
|
||||||
|
const fullName = `${this.id}_${Util.escapeUdid(device.udid)}`;
|
||||||
|
const isActive = device.state === DeviceState.DEVICE;
|
||||||
|
let hasPid = false;
|
||||||
|
const servicesId = `device_services_${fullName}`;
|
||||||
|
const row = html`<div class="device ${isActive ? 'active' : 'not-active'}">
|
||||||
|
<div class="device-header">
|
||||||
|
<div class="device-name">${device['ro.product.manufacturer']} ${device['ro.product.model']}</div>
|
||||||
|
<div class="device-serial">${device.udid}</div>
|
||||||
|
<div class="device-version">
|
||||||
|
<div class="release-version">${device['ro.build.version.release']}</div>
|
||||||
|
<div class="sdk-version">${device['ro.build.version.sdk']}</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-state" title="State: ${device.state}"></div>
|
||||||
|
</div>
|
||||||
|
<div id="${servicesId}" class="services"></div>
|
||||||
|
</div>`.content;
|
||||||
|
const services = row.getElementById(servicesId);
|
||||||
|
if (!services) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceTracker.tools.forEach((tool) => {
|
||||||
|
const entry = tool.createEntryForDeviceList(device, blockClass, this.params);
|
||||||
|
if (entry) {
|
||||||
|
if (Array.isArray(entry)) {
|
||||||
|
entry.forEach((item) => {
|
||||||
|
item && services.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
services.appendChild(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamEntry = StreamClientScrcpy.createEntryForDeviceList(device, blockClass, fullName, this.params);
|
||||||
|
streamEntry && services.appendChild(streamEntry);
|
||||||
|
|
||||||
|
DESC_COLUMNS.forEach((item) => {
|
||||||
|
const { title } = item;
|
||||||
|
const fieldName = item.field;
|
||||||
|
let value: string;
|
||||||
|
if (typeof item.field === 'string') {
|
||||||
|
value = '' + device[item.field];
|
||||||
|
} else {
|
||||||
|
value = item.field(device);
|
||||||
|
}
|
||||||
|
const td = document.createElement('div');
|
||||||
|
td.classList.add(DeviceTracker.titleToClassName(title), blockClass);
|
||||||
|
services.appendChild(td);
|
||||||
|
if (fieldName === 'pid') {
|
||||||
|
hasPid = value !== '-1';
|
||||||
|
const actionButton = document.createElement('button');
|
||||||
|
actionButton.className = 'action-button kill-server-button';
|
||||||
|
actionButton.setAttribute(Attribute.UDID, device.udid);
|
||||||
|
actionButton.setAttribute(Attribute.PID, value);
|
||||||
|
let command: string;
|
||||||
|
if (isActive) {
|
||||||
|
actionButton.classList.add('active');
|
||||||
|
actionButton.onclick = this.onActionButtonClick;
|
||||||
|
if (hasPid) {
|
||||||
|
command = ControlCenterCommand.KILL_SERVER;
|
||||||
|
actionButton.title = 'Kill server';
|
||||||
|
actionButton.appendChild(SvgImage.create(SvgImage.Icon.CANCEL));
|
||||||
|
} else {
|
||||||
|
command = ControlCenterCommand.START_SERVER;
|
||||||
|
actionButton.title = 'Start server';
|
||||||
|
actionButton.appendChild(SvgImage.create(SvgImage.Icon.REFRESH));
|
||||||
|
}
|
||||||
|
actionButton.setAttribute(Attribute.COMMAND, command);
|
||||||
|
} else {
|
||||||
|
const timestamp = device['last.update.timestamp'];
|
||||||
|
if (timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
actionButton.title = `Last update on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}`;
|
||||||
|
} else {
|
||||||
|
actionButton.title = `Not active`;
|
||||||
|
}
|
||||||
|
actionButton.appendChild(SvgImage.create(SvgImage.Icon.OFFLINE));
|
||||||
|
}
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.innerText = value;
|
||||||
|
actionButton.appendChild(span);
|
||||||
|
td.appendChild(actionButton);
|
||||||
|
} else if (fieldName === 'interfaces') {
|
||||||
|
const proxyInterfaceUrl = DeviceTracker.createUrl(this.params, device.udid).toString();
|
||||||
|
const proxyInterfaceName = 'proxy';
|
||||||
|
const localStorageKey = DeviceTracker.getLocalStorageKey(fullName);
|
||||||
|
const lastSelected = localStorage && localStorage.getItem(localStorageKey);
|
||||||
|
const selectElement = document.createElement('select');
|
||||||
|
selectElement.setAttribute(Attribute.UDID, device.udid);
|
||||||
|
selectElement.setAttribute(Attribute.FULL_NAME, fullName);
|
||||||
|
selectElement.setAttribute(
|
||||||
|
'name',
|
||||||
|
encodeURIComponent(`${DeviceTracker.AttributePrefixInterfaceSelectFor}${fullName}`),
|
||||||
|
);
|
||||||
|
/// #if SCRCPY_LISTENS_ON_ALL_INTERFACES
|
||||||
|
device.interfaces.forEach((value) => {
|
||||||
|
const params = {
|
||||||
|
...this.params,
|
||||||
|
secure: false,
|
||||||
|
hostname: value.ipv4,
|
||||||
|
port: SERVER_PORT,
|
||||||
|
};
|
||||||
|
const url = DeviceTracker.createUrl(params).toString();
|
||||||
|
const optionElement = DeviceTracker.createInterfaceOption(value.name, url);
|
||||||
|
optionElement.innerText = `${value.name}: ${value.ipv4}`;
|
||||||
|
selectElement.appendChild(optionElement);
|
||||||
|
if (lastSelected) {
|
||||||
|
if (lastSelected === value.name || !selectedInterfaceName) {
|
||||||
|
optionElement.selected = true;
|
||||||
|
selectedInterfaceUrl = url;
|
||||||
|
selectedInterfaceName = value.name;
|
||||||
|
}
|
||||||
|
} else if (device['wifi.interface'] === value.name) {
|
||||||
|
optionElement.selected = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
/// #else
|
||||||
|
selectedInterfaceUrl = proxyInterfaceUrl;
|
||||||
|
selectedInterfaceName = proxyInterfaceName;
|
||||||
|
td.classList.add('hidden');
|
||||||
|
/// #endif
|
||||||
|
if (isActive) {
|
||||||
|
const adbProxyOption = DeviceTracker.createInterfaceOption(proxyInterfaceName, proxyInterfaceUrl);
|
||||||
|
if (lastSelected === proxyInterfaceName || !selectedInterfaceName) {
|
||||||
|
adbProxyOption.selected = true;
|
||||||
|
selectedInterfaceUrl = proxyInterfaceUrl;
|
||||||
|
selectedInterfaceName = proxyInterfaceName;
|
||||||
|
}
|
||||||
|
selectElement.appendChild(adbProxyOption);
|
||||||
|
const actionButton = document.createElement('button');
|
||||||
|
actionButton.className = 'action-button update-interfaces-button active';
|
||||||
|
actionButton.title = `Update information`;
|
||||||
|
actionButton.appendChild(SvgImage.create(SvgImage.Icon.REFRESH));
|
||||||
|
actionButton.setAttribute(Attribute.UDID, device.udid);
|
||||||
|
actionButton.setAttribute(Attribute.COMMAND, ControlCenterCommand.UPDATE_INTERFACES);
|
||||||
|
actionButton.onclick = this.onActionButtonClick;
|
||||||
|
td.appendChild(actionButton);
|
||||||
|
}
|
||||||
|
selectElement.onchange = this.onInterfaceSelected;
|
||||||
|
td.appendChild(selectElement);
|
||||||
|
} else {
|
||||||
|
td.innerText = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DeviceTracker.CREATE_DIRECT_LINKS) {
|
||||||
|
const name = `${DeviceTracker.AttributePrefixPlayerFor}${fullName}`;
|
||||||
|
StreamClientScrcpy.getPlayers().forEach((playerClass) => {
|
||||||
|
const { playerCodeName, playerFullName } = playerClass;
|
||||||
|
const playerTd = document.createElement('div');
|
||||||
|
playerTd.classList.add(blockClass);
|
||||||
|
playerTd.setAttribute('name', encodeURIComponent(name));
|
||||||
|
playerTd.setAttribute(DeviceTracker.AttributePlayerFullName, encodeURIComponent(playerFullName));
|
||||||
|
playerTd.setAttribute(DeviceTracker.AttributePlayerCodeName, encodeURIComponent(playerCodeName));
|
||||||
|
services.appendChild(playerTd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
if (DeviceTracker.CREATE_DIRECT_LINKS && hasPid && selectedInterfaceUrl) {
|
||||||
|
this.updateLink({
|
||||||
|
url: selectedInterfaceUrl,
|
||||||
|
name: selectedInterfaceName,
|
||||||
|
fullName,
|
||||||
|
udid: device.udid,
|
||||||
|
store: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelCode(): string {
|
||||||
|
return ChannelCode.GTRC;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
super.destroy();
|
||||||
|
DeviceTracker.instancesByUrl.delete(this.url.toString());
|
||||||
|
if (!DeviceTracker.instancesByUrl.size) {
|
||||||
|
const holder = document.getElementById(BaseDeviceTracker.HOLDER_ELEMENT_ID);
|
||||||
|
if (holder && holder.parentElement) {
|
||||||
|
holder.parentElement.removeChild(holder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
381
src/app/googDevice/client/DevtoolsClient.ts
Normal file
381
src/app/googDevice/client/DevtoolsClient.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import '../../../style/devtools.css';
|
||||||
|
import { ManagerClient } from '../../client/ManagerClient';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import { ParamsDevtools } from '../../../types/ParamsDevtools';
|
||||||
|
import { RemoteDevtoolsCommand } from '../../../types/RemoteDevtoolsCommand';
|
||||||
|
import { Message } from '../../../types/Message';
|
||||||
|
import { DevtoolsInfo, RemoteBrowserInfo, RemoteTarget, TargetDescription } from '../../../types/RemoteDevtools';
|
||||||
|
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
|
||||||
|
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
|
||||||
|
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
|
||||||
|
import Util from '../../Util';
|
||||||
|
|
||||||
|
const FRONTEND_RE = /^https?:\/\/chrome-devtools-frontend\.appspot\.com\/serve_rev\/(@.*)/;
|
||||||
|
|
||||||
|
const TAG = '[DevtoolsClient]';
|
||||||
|
|
||||||
|
export class DevtoolsClient extends ManagerClient<ParamsDevtools, never> {
|
||||||
|
public static readonly ACTION = ACTION.DEVTOOLS;
|
||||||
|
public static readonly TIMEOUT = 1000;
|
||||||
|
|
||||||
|
public static start(params: ParamsDevtools): DevtoolsClient {
|
||||||
|
return new DevtoolsClient(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private timeout?: number;
|
||||||
|
private readonly hiddenInput: HTMLInputElement;
|
||||||
|
private readonly tooltip: HTMLSpanElement;
|
||||||
|
private hideTimeout?: number;
|
||||||
|
private readonly udid: string;
|
||||||
|
constructor(params: ParamsDevtools) {
|
||||||
|
super(params);
|
||||||
|
this.udid = this.params.udid;
|
||||||
|
this.openNewConnection();
|
||||||
|
this.setTitle(`Devtools ${this.udid}`);
|
||||||
|
this.setBodyClass('devtools');
|
||||||
|
this.hiddenInput = document.createElement('input');
|
||||||
|
this.hiddenInput.className = 'hidden';
|
||||||
|
this.hiddenInput.setAttribute('hidden', 'hidden');
|
||||||
|
document.body.appendChild(this.hiddenInput);
|
||||||
|
this.tooltip = document.createElement('span');
|
||||||
|
this.tooltip.innerText = 'Copied!';
|
||||||
|
this.tooltip.className = 'tooltip';
|
||||||
|
this.tooltip.style.display = 'none';
|
||||||
|
document.body.appendChild(this.tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseParameters(params: URLSearchParams): ParamsDevtools {
|
||||||
|
const typedParams = super.parseParameters(params);
|
||||||
|
const { action } = typedParams;
|
||||||
|
if (action !== ACTION.DEVTOOLS) {
|
||||||
|
throw Error('Incorrect action');
|
||||||
|
}
|
||||||
|
return { ...typedParams, action, udid: Util.parseString(params, 'udid', true) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static compareBrowsers = (a: RemoteBrowserInfo, b: RemoteBrowserInfo): number => {
|
||||||
|
const aBrowser = a.version.Browser;
|
||||||
|
const bBrowser = b.version.Browser;
|
||||||
|
if (aBrowser > bBrowser) {
|
||||||
|
return 1;
|
||||||
|
} else if (aBrowser < bBrowser) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected buildDirectWebSocketUrl(): URL {
|
||||||
|
const localUrl = super.buildDirectWebSocketUrl();
|
||||||
|
if (typeof this.params.udid === 'string') {
|
||||||
|
localUrl.searchParams.set('udid', this.params.udid);
|
||||||
|
}
|
||||||
|
return localUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketClose(event: CloseEvent): void {
|
||||||
|
console.error(TAG, `Socket closed. Code: ${event.code}.${event.reason ? ' Reason: ' + event.reason : ''}`);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.openNewConnection();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketMessage(event: MessageEvent): void {
|
||||||
|
console.log("接收到的参数234", event.data)
|
||||||
|
|
||||||
|
let message: Message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(TAG, error.message);
|
||||||
|
console.log(TAG, error.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type !== DevtoolsClient.ACTION) {
|
||||||
|
console.log(TAG, `Unknown message type: ${message.type}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = message.data as DevtoolsInfo;
|
||||||
|
this.buildList(list);
|
||||||
|
if (!this.timeout) {
|
||||||
|
this.timeout = window.setTimeout(this.requestListUpdate, DevtoolsClient.TIMEOUT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketOpen(): void {
|
||||||
|
this.requestListUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestListUpdate = (): void => {
|
||||||
|
this.timeout = undefined;
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('发送的参数4');
|
||||||
|
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
command: RemoteDevtoolsCommand.LIST_DEVTOOLS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private createDeviceBlock(info: DevtoolsInfo): HTMLDivElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'device';
|
||||||
|
d.id = `device:${info.deviceSerial}`;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
private createDeviceHeader(info: DevtoolsInfo): HTMLDivElement {
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.className = 'device-header';
|
||||||
|
const n = document.createElement('div');
|
||||||
|
n.className = 'device-name';
|
||||||
|
n.innerText = info.deviceName;
|
||||||
|
const s = document.createElement('div');
|
||||||
|
s.className = 'device-serial';
|
||||||
|
s.innerText = `#${info.deviceSerial.toUpperCase()}`;
|
||||||
|
const p = document.createElement('div');
|
||||||
|
p.className = 'device-ports';
|
||||||
|
h.appendChild(n);
|
||||||
|
h.appendChild(s);
|
||||||
|
h.appendChild(p);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBrowsersBlock(info: DevtoolsInfo): HTMLDivElement {
|
||||||
|
const { deviceSerial } = info;
|
||||||
|
const bs = document.createElement('div');
|
||||||
|
bs.className = 'browsers';
|
||||||
|
info.browsers.sort(DevtoolsClient.compareBrowsers).forEach((browser) => {
|
||||||
|
const b = this.createBrowserBlock(deviceSerial, browser);
|
||||||
|
bs.appendChild(b);
|
||||||
|
});
|
||||||
|
return bs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBrowserBlock(serial: string, info: RemoteBrowserInfo): HTMLDivElement {
|
||||||
|
const { socket } = info;
|
||||||
|
const b = document.createElement('div');
|
||||||
|
b.id = `${serial}:${socket}`;
|
||||||
|
b.className = 'browser';
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.className = 'browser-header';
|
||||||
|
b.appendChild(h);
|
||||||
|
const n = document.createElement('div');
|
||||||
|
n.className = 'browser-name';
|
||||||
|
|
||||||
|
h.appendChild(n);
|
||||||
|
const pkg = info.version['Android-Package'];
|
||||||
|
const browser = info.version.Browser;
|
||||||
|
let version: string;
|
||||||
|
const temp = browser.split('/');
|
||||||
|
if (temp.length > 1) {
|
||||||
|
version = temp[1];
|
||||||
|
} else {
|
||||||
|
version = browser;
|
||||||
|
}
|
||||||
|
const prefix = socket.indexOf('webview') === 0 ? 'WebView in ' : '';
|
||||||
|
n.innerText = `${prefix}${pkg}(${version})`;
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.setAttribute('tabIndex', '1');
|
||||||
|
s.className = 'action';
|
||||||
|
s.innerText = 'trace';
|
||||||
|
s.setAttribute('hidden', 'hidden');
|
||||||
|
h.appendChild(s);
|
||||||
|
const pages = document.createElement('div');
|
||||||
|
pages.className = 'list pages';
|
||||||
|
info.targets.forEach((page) => {
|
||||||
|
pages.appendChild(this.createPageBlock(page, version));
|
||||||
|
});
|
||||||
|
b.appendChild(pages);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPageBlock(page: RemoteTarget, version?: string): HTMLDivElement {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row';
|
||||||
|
const props = document.createElement('div');
|
||||||
|
props.className = 'properties-box';
|
||||||
|
row.appendChild(props);
|
||||||
|
if (page.faviconUrl) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = page.faviconUrl;
|
||||||
|
props.appendChild(img);
|
||||||
|
}
|
||||||
|
const subrow = document.createElement('div');
|
||||||
|
subrow.className = 'subrow-box';
|
||||||
|
props.appendChild(subrow);
|
||||||
|
const sub1 = document.createElement('div');
|
||||||
|
sub1.className = 'subrow';
|
||||||
|
subrow.appendChild(sub1);
|
||||||
|
const n = document.createElement('div');
|
||||||
|
n.className = 'name';
|
||||||
|
if (page.title) {
|
||||||
|
n.innerText = page.title;
|
||||||
|
}
|
||||||
|
sub1.appendChild(n);
|
||||||
|
const u = document.createElement('div');
|
||||||
|
u.className = 'url';
|
||||||
|
if (page.url) {
|
||||||
|
u.innerText = page.url;
|
||||||
|
}
|
||||||
|
sub1.appendChild(u);
|
||||||
|
const sub2 = document.createElement('div');
|
||||||
|
sub2.className = 'subrow webview';
|
||||||
|
subrow.appendChild(sub2);
|
||||||
|
if (page.description) {
|
||||||
|
try {
|
||||||
|
const desc = JSON.parse(page.description) as TargetDescription;
|
||||||
|
const position = document.createElement('div');
|
||||||
|
position.className = 'position';
|
||||||
|
position.innerText = `at (${desc.screenX}, ${desc.screenY})`;
|
||||||
|
sub2.appendChild(position);
|
||||||
|
const size = document.createElement('div');
|
||||||
|
size.className = 'size';
|
||||||
|
size.innerText = `size ${desc.width} × ${desc.height}`;
|
||||||
|
sub2.appendChild(size);
|
||||||
|
} catch (error: any) { }
|
||||||
|
}
|
||||||
|
const absoluteAddress = page.devtoolsFrontendUrl && page.devtoolsFrontendUrl.startsWith('http');
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'actions';
|
||||||
|
subrow.appendChild(actions);
|
||||||
|
const inspect = document.createElement('a');
|
||||||
|
inspect.setAttribute('tabIndex', '1');
|
||||||
|
inspect.className = 'action';
|
||||||
|
inspect.innerText = 'inspect';
|
||||||
|
actions.appendChild(inspect);
|
||||||
|
|
||||||
|
if (page.devtoolsFrontendUrl) {
|
||||||
|
inspect.setAttribute('href', page.devtoolsFrontendUrl);
|
||||||
|
inspect.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
inspect.setAttribute('target', '_blank');
|
||||||
|
} else {
|
||||||
|
inspect.classList.add('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!absoluteAddress) {
|
||||||
|
inspect.classList.add('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.webSocketDebuggerUrl) {
|
||||||
|
const bundled = document.createElement('a');
|
||||||
|
bundled.setAttribute('tabIndex', '1');
|
||||||
|
bundled.className = 'action copy';
|
||||||
|
bundled.innerText = 'bundled';
|
||||||
|
bundled.title = 'Copy link and open manually';
|
||||||
|
actions.appendChild(bundled);
|
||||||
|
|
||||||
|
const base = 'devtools://devtools/bundled/inspector.html?experiments=true&ws=';
|
||||||
|
bundled.setAttribute('href', `${base}${page.webSocketDebuggerUrl}`);
|
||||||
|
bundled.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
bundled.setAttribute('target', '_blank');
|
||||||
|
bundled.onclick = this.onDevtoolsLinkClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.devtoolsFrontendUrl && page.webSocketDebuggerUrl && absoluteAddress) {
|
||||||
|
const ur = new URL(page.devtoolsFrontendUrl);
|
||||||
|
ur.searchParams.delete('ws');
|
||||||
|
const urStr = ur.toString();
|
||||||
|
const match = urStr.match(FRONTEND_RE);
|
||||||
|
if (match) {
|
||||||
|
const str = match[1];
|
||||||
|
const temp = str.split('/');
|
||||||
|
const revision = temp.shift();
|
||||||
|
const rest = temp.join('/');
|
||||||
|
const remoteVersion = version ? `remoteVersion=${version}&` : '';
|
||||||
|
const opts = `remoteFrontend=true&dockSide=undocked&`;
|
||||||
|
const ws = `ws=${page.webSocketDebuggerUrl}`;
|
||||||
|
const url = `devtools://devtools/remote/serve_rev/${revision}/${rest}?${remoteVersion}${opts}${ws}`;
|
||||||
|
|
||||||
|
const remote = document.createElement('a');
|
||||||
|
remote.setAttribute('tabIndex', '1');
|
||||||
|
remote.className = 'action copy';
|
||||||
|
remote.innerText = 'remote';
|
||||||
|
remote.title = 'Copy link and open manually';
|
||||||
|
actions.appendChild(remote);
|
||||||
|
|
||||||
|
remote.setAttribute('href', url);
|
||||||
|
remote.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
remote.setAttribute('target', '_blank');
|
||||||
|
remote.onclick = this.onDevtoolsLinkClick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pause = document.createElement('span');
|
||||||
|
pause.setAttribute('hidden', 'hidden');
|
||||||
|
pause.setAttribute('tabIndex', '1');
|
||||||
|
pause.className = 'action';
|
||||||
|
pause.innerText = 'pause';
|
||||||
|
actions.appendChild(pause);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDevtoolsLinkClick = (event: MouseEvent): void => {
|
||||||
|
const a = event.target as HTMLAnchorElement;
|
||||||
|
const url = a.getAttribute('href');
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.hiddenInput.value = url;
|
||||||
|
this.hiddenInput.removeAttribute('hidden');
|
||||||
|
this.hiddenInput.select();
|
||||||
|
this.hiddenInput.setSelectionRange(0, url.length);
|
||||||
|
document.execCommand('copy');
|
||||||
|
this.hiddenInput.setAttribute('hidden', 'hidden');
|
||||||
|
this.tooltip.style.left = `${event.clientX}px`;
|
||||||
|
this.tooltip.style.top = `${event.clientY}px`;
|
||||||
|
this.tooltip.style.display = 'block';
|
||||||
|
this.hideTooltip();
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
private hideTooltip() {
|
||||||
|
if (this.hideTimeout) {
|
||||||
|
clearTimeout(this.hideTimeout);
|
||||||
|
}
|
||||||
|
this.hideTimeout = window.setTimeout(() => {
|
||||||
|
this.hideTimeout = undefined;
|
||||||
|
this.tooltip.style.display = 'none';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildList(info: DevtoolsInfo): void {
|
||||||
|
// console.log(info);
|
||||||
|
const block = this.createDeviceBlock(info);
|
||||||
|
const header = this.createDeviceHeader(info);
|
||||||
|
const browsers = this.createBrowsersBlock(info);
|
||||||
|
block.appendChild(header);
|
||||||
|
block.appendChild(browsers);
|
||||||
|
const old = document.getElementById(block.id);
|
||||||
|
if (old) {
|
||||||
|
old.parentElement?.replaceChild(block, old);
|
||||||
|
} else {
|
||||||
|
document.body.appendChild(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createEntryForDeviceList(
|
||||||
|
descriptor: GoogDeviceDescriptor,
|
||||||
|
blockClass: string,
|
||||||
|
params: ParamsDeviceTracker,
|
||||||
|
): HTMLElement | DocumentFragment | undefined {
|
||||||
|
if (descriptor.state !== 'device') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.classList.add('devtools', blockClass);
|
||||||
|
entry.appendChild(
|
||||||
|
BaseDeviceTracker.buildLink(
|
||||||
|
{
|
||||||
|
action: ACTION.DEVTOOLS,
|
||||||
|
udid: descriptor.udid,
|
||||||
|
},
|
||||||
|
'devtools',
|
||||||
|
params,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
596
src/app/googDevice/client/FileListingClient.ts
Normal file
596
src/app/googDevice/client/FileListingClient.ts
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
import '../../../style/filelisting.css';
|
||||||
|
import { ParamsFileListing } from '../../../types/ParamsFileListing';
|
||||||
|
import { ManagerClient } from '../../client/ManagerClient';
|
||||||
|
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
|
||||||
|
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
|
||||||
|
import Util from '../../Util';
|
||||||
|
import Protocol from '@dead50f7/adbkit/lib/adb/protocol';
|
||||||
|
import { Entry } from '../Entry';
|
||||||
|
import { html } from '../../ui/HtmlTag';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { ChannelCode } from '../../../common/ChannelCode';
|
||||||
|
import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
|
||||||
|
import FilePushHandler, { DragAndPushListener, PushUpdateParams } from '../filePush/FilePushHandler';
|
||||||
|
import { AdbkitFilePushStream } from '../filePush/AdbkitFilePushStream';
|
||||||
|
|
||||||
|
const TAG = '[FileListing]';
|
||||||
|
|
||||||
|
const parentDirLinkBox = 'parentDirLinkBox';
|
||||||
|
const rootDirLinkBox = 'rootDirLinkBox';
|
||||||
|
const tempDirLinkBox = 'tempDirLinkBox';
|
||||||
|
const storageDirLinkBox = 'storageDirLinkBox';
|
||||||
|
|
||||||
|
const rootPath = '/';
|
||||||
|
const tempPath = '/data/local/tmp';
|
||||||
|
const storagePath = '/storage';
|
||||||
|
|
||||||
|
type Download = {
|
||||||
|
receivedBytes: number;
|
||||||
|
entry?: Entry;
|
||||||
|
progressEl?: HTMLElement;
|
||||||
|
anchor?: HTMLElement;
|
||||||
|
chunks: Uint8Array[];
|
||||||
|
path: string;
|
||||||
|
pathToLoadAfter: string;
|
||||||
|
};
|
||||||
|
type Upload = { row: HTMLElement; progressEl: HTMLElement; anchor: HTMLElement; timeout: number | null };
|
||||||
|
|
||||||
|
enum Foreground {
|
||||||
|
Drop = 'drop-target',
|
||||||
|
Connect = 'connect',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Message: Record<Foreground, string> = {
|
||||||
|
[Foreground.Drop]: 'Drop files here',
|
||||||
|
[Foreground.Connect]: 'Connection lost',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FileListingClient extends ManagerClient<ParamsFileListing, never> implements DragAndPushListener {
|
||||||
|
public static readonly ACTION = ACTION.FILE_LISTING;
|
||||||
|
public static readonly PARENT_DIR = '..';
|
||||||
|
public static readonly PROPERTY_NAME = 'data-name';
|
||||||
|
public static readonly PROPERTY_ENTRY_ID = 'data-entry-id';
|
||||||
|
public static REMOVE_ROW_TIMEOUT = 2000;
|
||||||
|
|
||||||
|
public static start(params: ParamsFileListing): FileListingClient {
|
||||||
|
return new FileListingClient(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createEntryForDeviceList(
|
||||||
|
descriptor: GoogDeviceDescriptor,
|
||||||
|
blockClass: string,
|
||||||
|
params: ParamsDeviceTracker,
|
||||||
|
): HTMLElement | DocumentFragment | undefined {
|
||||||
|
if (descriptor.state !== 'device') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.classList.add('file-listing', blockClass);
|
||||||
|
entry.appendChild(
|
||||||
|
BaseDeviceTracker.buildLink(
|
||||||
|
{
|
||||||
|
action: ACTION.FILE_LISTING,
|
||||||
|
udid: descriptor.udid,
|
||||||
|
path: `${tempPath}/`,
|
||||||
|
},
|
||||||
|
'list files',
|
||||||
|
params,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly serial: string;
|
||||||
|
private readonly name: string;
|
||||||
|
private readonly tableBodyId: string;
|
||||||
|
private readonly wrapperId: string;
|
||||||
|
private readonly filePushHandler?: FilePushHandler;
|
||||||
|
private readonly parent: HTMLElement;
|
||||||
|
private enterCount = 0;
|
||||||
|
private entries: Entry[] = [];
|
||||||
|
private path: string;
|
||||||
|
private requireClean = false;
|
||||||
|
private requestedPath = '';
|
||||||
|
private downloads: Map<Multiplexer, Download> = new Map();
|
||||||
|
private uploads: Map<string, Upload> = new Map();
|
||||||
|
private tableBody: HTMLElement;
|
||||||
|
private channels: Set<Multiplexer> = new Set();
|
||||||
|
constructor(params: ParamsFileListing) {
|
||||||
|
super(params);
|
||||||
|
this.parent = document.body;
|
||||||
|
this.serial = this.params.udid;
|
||||||
|
this.path = this.params.path;
|
||||||
|
this.openNewConnection();
|
||||||
|
this.setTitle(`Listing ${this.serial}`);
|
||||||
|
this.setBodyClass('file-listing');
|
||||||
|
this.name = `${TAG} [${this.serial}]`;
|
||||||
|
this.tableBodyId = `${Util.escapeUdid(this.serial)}_list`;
|
||||||
|
this.wrapperId = `wrapper_${this.tableBodyId}`;
|
||||||
|
const fragment = html`<div id="${this.wrapperId}" class="listing">
|
||||||
|
<h1 id="header">Contents ${this.path}</h1>
|
||||||
|
<div id="${parentDirLinkBox}" class="quick-link-box">
|
||||||
|
<a class="icon up" href="#!" ${FileListingClient.PROPERTY_NAME}=".."> [parent] </a>
|
||||||
|
</div>
|
||||||
|
<div id="${rootDirLinkBox}" class="quick-link-box">
|
||||||
|
<a class="icon dir" href="#!" ${FileListingClient.PROPERTY_NAME}="${rootPath}"> [root] </a>
|
||||||
|
</div>
|
||||||
|
<div id="${storageDirLinkBox}" class="quick-link-box">
|
||||||
|
<a class="icon dir" href="#!" ${FileListingClient.PROPERTY_NAME}="${storagePath}/"> [storage] </a>
|
||||||
|
</div>
|
||||||
|
<div id="${tempDirLinkBox}" class="quick-link-box">
|
||||||
|
<a class="icon dir" href="#!" ${FileListingClient.PROPERTY_NAME}="${tempPath}/"> [temp] </a>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>MTime</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="${this.tableBodyId}"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>`.content;
|
||||||
|
this.tableBody = fragment.getElementById(this.tableBodyId) as HTMLElement;
|
||||||
|
const wrapper = fragment.getElementById(this.wrapperId);
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.addEventListener('click', (e) => {
|
||||||
|
if (!e.target || !(e.target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = e.target.getAttribute(FileListingClient.PROPERTY_NAME);
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
e.cancelBubble = true;
|
||||||
|
const newPath = path.resolve(this.path, name);
|
||||||
|
if (newPath !== this.path) {
|
||||||
|
const entryIdString = e.target.getAttribute(FileListingClient.PROPERTY_ENTRY_ID);
|
||||||
|
let entry: Entry | undefined;
|
||||||
|
let anchor: HTMLElement | undefined;
|
||||||
|
if (entryIdString) {
|
||||||
|
const entryId = parseInt(entryIdString, 10);
|
||||||
|
if (!isNaN(entryId) && this.entries[entryId]) {
|
||||||
|
entry = this.entries[entryId];
|
||||||
|
anchor = e.target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.loadContent(newPath, entry, anchor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.ws instanceof Multiplexer) {
|
||||||
|
this.filePushHandler = new FilePushHandler(this.parent, new AdbkitFilePushStream(this.ws, this));
|
||||||
|
this.filePushHandler.addEventListener(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.parent.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragEnter(): boolean {
|
||||||
|
if (this.enterCount === 0) {
|
||||||
|
this.addForeground(Foreground.Drop);
|
||||||
|
}
|
||||||
|
this.enterCount++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragLeave(): boolean {
|
||||||
|
this.enterCount--;
|
||||||
|
if (this.enterCount < 0) {
|
||||||
|
this.enterCount = 0;
|
||||||
|
}
|
||||||
|
if (this.enterCount === 0) {
|
||||||
|
this.removeForeground(Foreground.Drop);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDrop(): boolean {
|
||||||
|
this.enterCount = 0;
|
||||||
|
this.removeForeground(Foreground.Drop);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findOrCreateEntryRow(fileName: string): HTMLElement {
|
||||||
|
const row = document.getElementById(`entry-${fileName}`);
|
||||||
|
if (row) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
return this.addRow(true, fileName, 'file');
|
||||||
|
}
|
||||||
|
|
||||||
|
public onFilePushUpdate(data: PushUpdateParams): void {
|
||||||
|
const { fileName, progress, error, message, finished } = data;
|
||||||
|
let upload = this.uploads.get(fileName);
|
||||||
|
if (!upload || document.getElementById(upload.anchor.id) !== upload.anchor) {
|
||||||
|
const row = this.findOrCreateEntryRow(fileName);
|
||||||
|
const anchor = row.getElementsByTagName('a')[0];
|
||||||
|
if (!anchor.id) {
|
||||||
|
anchor.id = `upload_${fileName}`;
|
||||||
|
}
|
||||||
|
const progressEl = this.appendProgressElement(anchor);
|
||||||
|
upload = { row, progressEl, anchor, timeout: null };
|
||||||
|
this.uploads.set(fileName, upload);
|
||||||
|
}
|
||||||
|
const { row, progressEl, anchor } = upload;
|
||||||
|
if (error) {
|
||||||
|
this.uploads.delete(fileName);
|
||||||
|
progressEl.style.width = `100%`;
|
||||||
|
progressEl.classList.add('error');
|
||||||
|
if (!anchor.classList.contains('error')) {
|
||||||
|
anchor.classList.add('error');
|
||||||
|
anchor.innerText = `${fileName}. ${message}`;
|
||||||
|
}
|
||||||
|
if (!upload.timeout) {
|
||||||
|
upload.timeout = window.setTimeout(() => {
|
||||||
|
const parent = row.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.removeChild(row);
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
}, FileListingClient.REMOVE_ROW_TIMEOUT);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
anchor.innerText = `${fileName}. ${message}`;
|
||||||
|
progressEl.style.width = `${progress}%`;
|
||||||
|
}
|
||||||
|
if (finished && !error) {
|
||||||
|
this.uploads.delete(fileName);
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public onError(error: string | Error): void {
|
||||||
|
console.error(this.name, 'FIXME: implement', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addForeground(type: Foreground): void {
|
||||||
|
const fragment = html`<div class="foreground ${type}">
|
||||||
|
<div class="foreground-message ${type}-message">${Message[type]}</div>
|
||||||
|
</div>`.content;
|
||||||
|
this.parent.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeForeground(type: Foreground): void {
|
||||||
|
const els = this.parent.getElementsByClassName(type);
|
||||||
|
Array.from(els).forEach((el) => {
|
||||||
|
this.parent.removeChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseParameters(params: URLSearchParams): ParamsFileListing {
|
||||||
|
const typedParams = super.parseParameters(params);
|
||||||
|
const { action } = typedParams;
|
||||||
|
if (action !== ACTION.FILE_LISTING) {
|
||||||
|
throw Error('Incorrect action');
|
||||||
|
}
|
||||||
|
const pathParam = params.get('path');
|
||||||
|
const path = pathParam || '/data/local/tmp';
|
||||||
|
return { ...typedParams, action, udid: Util.parseString(params, 'udid', true), path };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildDirectWebSocketUrl(): URL {
|
||||||
|
const localUrl = super.buildDirectWebSocketUrl();
|
||||||
|
localUrl.searchParams.set('action', ACTION.MULTIPLEX);
|
||||||
|
return localUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketClose(event: CloseEvent): void {
|
||||||
|
if (this.filePushHandler) {
|
||||||
|
this.filePushHandler.release();
|
||||||
|
}
|
||||||
|
console.error(this.name, 'socket closed', event.reason);
|
||||||
|
this.addForeground(Foreground.Connect);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketMessage(_e: MessageEvent): void {
|
||||||
|
// We create separate channel for each request
|
||||||
|
// Don't expect any messages on this level
|
||||||
|
console.log("接收到的参数234", _e.data)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketOpen(): void {
|
||||||
|
this.loadContent(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected loadContent(path: string, entry?: Entry, anchor?: HTMLElement, pathToLoadAfter = ''): void {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN || !(this.ws instanceof Multiplexer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!entry && (this.channels.size || this.uploads.size)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.requireClean = true;
|
||||||
|
this.requestedPath = path;
|
||||||
|
let cmd: string;
|
||||||
|
if (!entry) {
|
||||||
|
cmd = Protocol.STAT;
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
cmd = Protocol.RECV;
|
||||||
|
} else {
|
||||||
|
cmd = Protocol.LIST;
|
||||||
|
}
|
||||||
|
const len = Buffer.byteLength(path, 'utf-8');
|
||||||
|
const payload = Buffer.alloc(cmd.length + 4 + len);
|
||||||
|
let pos = payload.write(cmd, 0);
|
||||||
|
pos = payload.writeUInt32LE(len, pos);
|
||||||
|
payload.write(path, pos);
|
||||||
|
const channel = this.ws.createChannel(payload);
|
||||||
|
this.channels.add(channel);
|
||||||
|
const download: Download = {
|
||||||
|
receivedBytes: 0,
|
||||||
|
path,
|
||||||
|
entry,
|
||||||
|
anchor,
|
||||||
|
chunks: [],
|
||||||
|
pathToLoadAfter,
|
||||||
|
};
|
||||||
|
this.downloads.set(channel, download);
|
||||||
|
const onMessage = (event: MessageEvent): void => {
|
||||||
|
this.handleReply(channel, event);
|
||||||
|
};
|
||||||
|
const onClose = (): void => {
|
||||||
|
this.channels.delete(channel);
|
||||||
|
this.downloads.delete(channel);
|
||||||
|
channel.removeEventListener('message', onMessage);
|
||||||
|
channel.removeEventListener('close', onClose);
|
||||||
|
};
|
||||||
|
channel.addEventListener('message', onMessage);
|
||||||
|
channel.addEventListener('close', onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clean(): void {
|
||||||
|
this.tableBody.innerHTML = '';
|
||||||
|
const header = document.getElementById('header');
|
||||||
|
if (header) {
|
||||||
|
header.innerText = `Content ${this.path}`;
|
||||||
|
}
|
||||||
|
this.toggleQuickLinks(this.path);
|
||||||
|
|
||||||
|
// FIXME: should do over way around: load content on hash change
|
||||||
|
const hash = location.hash.replace(/#!/, '');
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
if (params.get('action') === ACTION.FILE_LISTING) {
|
||||||
|
params.set('path', this.path);
|
||||||
|
location.hash = `#!${params.toString()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleQuickLinks(path: string): void {
|
||||||
|
const isRoot = path === rootPath;
|
||||||
|
const parentEl = document.getElementById(parentDirLinkBox);
|
||||||
|
if (parentEl) {
|
||||||
|
parentEl.classList.toggle('hidden', isRoot);
|
||||||
|
}
|
||||||
|
const rootEl = document.getElementById(rootDirLinkBox);
|
||||||
|
if (rootEl) {
|
||||||
|
rootEl.classList.toggle('hidden', isRoot);
|
||||||
|
}
|
||||||
|
const isTemp = path === tempPath;
|
||||||
|
const tempEl = document.getElementById(tempDirLinkBox);
|
||||||
|
if (tempEl) {
|
||||||
|
tempEl.classList.toggle('hidden', isTemp);
|
||||||
|
}
|
||||||
|
const isStorage = path === storagePath;
|
||||||
|
const storageEl = document.getElementById(storageDirLinkBox);
|
||||||
|
if (storageEl) {
|
||||||
|
storageEl.classList.toggle('hidden', isStorage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleReply(channel: Multiplexer, e: MessageEvent): void {
|
||||||
|
const data = Buffer.from(e.data);
|
||||||
|
const reply = data.slice(0, 4).toString('ascii');
|
||||||
|
switch (reply) {
|
||||||
|
case Protocol.DENT:
|
||||||
|
const stat = data.slice(4);
|
||||||
|
const mode = stat.readUInt32LE(0);
|
||||||
|
const size = stat.readUInt32LE(4);
|
||||||
|
const mtime = stat.readUInt32LE(8);
|
||||||
|
const namelen = stat.readUInt32LE(12);
|
||||||
|
const name = Util.utf8ByteArrayToString(stat.slice(16, 16 + namelen));
|
||||||
|
this.addEntry(new Entry(name, mode, size, mtime));
|
||||||
|
return;
|
||||||
|
case Protocol.DONE:
|
||||||
|
this.finishDownload(channel);
|
||||||
|
return;
|
||||||
|
case Protocol.STAT: {
|
||||||
|
const download = this.downloads.get(channel);
|
||||||
|
if (!download) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stat = data.slice(4);
|
||||||
|
const mode = stat.readUInt32LE(0);
|
||||||
|
const size = stat.readUInt32LE(4);
|
||||||
|
const mtime = stat.readUInt32LE(8);
|
||||||
|
const nameString = path.basename(download.path);
|
||||||
|
if (mode === 0) {
|
||||||
|
console.error('FIXME: show error in UI');
|
||||||
|
console.error(`Error: no entity "${download.path}"`);
|
||||||
|
this.channels.delete(channel);
|
||||||
|
this.loadContent(tempPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = new Entry(nameString, mode, size, mtime);
|
||||||
|
let anchor: HTMLElement | undefined;
|
||||||
|
let nextPath = '';
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
nextPath = this.requestedPath = path.dirname(download.path);
|
||||||
|
const row = this.addEntry(entry);
|
||||||
|
anchor = row ? row.getElementsByTagName('a')[0] : undefined;
|
||||||
|
}
|
||||||
|
this.loadContent(download.path, entry, anchor, nextPath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Protocol.FAIL:
|
||||||
|
const length = data.readUInt32LE(4);
|
||||||
|
const message = Util.utf8ByteArrayToString(data.slice(8, 8 + length));
|
||||||
|
console.error(TAG, `FAIL: ${message}`);
|
||||||
|
return;
|
||||||
|
case Protocol.DATA:
|
||||||
|
const download = this.downloads.get(channel);
|
||||||
|
if (!download) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
download.chunks.push(data.slice(4));
|
||||||
|
download.receivedBytes += data.length - 4;
|
||||||
|
if (download.anchor) {
|
||||||
|
let progressElement = download.progressEl;
|
||||||
|
if (!progressElement) {
|
||||||
|
progressElement = this.appendProgressElement(download.anchor);
|
||||||
|
download.progressEl = progressElement;
|
||||||
|
}
|
||||||
|
if (download.entry) {
|
||||||
|
const { size } = download.entry;
|
||||||
|
const percent = (download.receivedBytes * 100) / size;
|
||||||
|
progressElement.style.width = `${percent}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
console.error(`Unexpected "${reply}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected appendProgressElement(anchor: HTMLElement): HTMLElement {
|
||||||
|
const progressElement = document.createElement('span');
|
||||||
|
progressElement.className = 'background-progress';
|
||||||
|
const parent = anchor.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.appendChild(progressElement);
|
||||||
|
}
|
||||||
|
return progressElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addEntry(entry: Entry): HTMLElement | undefined {
|
||||||
|
if (this.requireClean) {
|
||||||
|
this.path = this.requestedPath;
|
||||||
|
this.requestedPath = '';
|
||||||
|
this.clean();
|
||||||
|
this.requireClean = false;
|
||||||
|
this.entries.length = 0;
|
||||||
|
}
|
||||||
|
this.entries.push(entry);
|
||||||
|
const entryId = (this.entries.length - 1).toString();
|
||||||
|
if (entry.name === '.') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.name === FileListingClient.PARENT_DIR) {
|
||||||
|
const el = document.getElementById(parentDirLinkBox);
|
||||||
|
if (el) {
|
||||||
|
const a = el.children[0];
|
||||||
|
if (a) {
|
||||||
|
a.setAttribute(FileListingClient.PROPERTY_ENTRY_ID, entryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = entry.isDirectory() ? 'dir' : entry.isSymbolicLink() ? 'link' : entry.isFile() ? 'file' : 'else';
|
||||||
|
const date = entry.mtime.toLocaleString();
|
||||||
|
return this.addRow(false, entry.name, type, entry.size.toString(), date, entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addRow(push: boolean, name: string, typeClass: string, size = '', date = '', entryId = ''): HTMLElement {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.id = `entry-${name}`;
|
||||||
|
row.classList.add('entry-row');
|
||||||
|
const nameTd = document.createElement('td');
|
||||||
|
nameTd.classList.add('entry-name');
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.classList.add('icon', typeClass);
|
||||||
|
link.setAttribute(FileListingClient.PROPERTY_NAME, name);
|
||||||
|
if (entryId) {
|
||||||
|
link.setAttribute(FileListingClient.PROPERTY_ENTRY_ID, entryId);
|
||||||
|
}
|
||||||
|
link.innerText = name;
|
||||||
|
nameTd.appendChild(link);
|
||||||
|
row.appendChild(nameTd);
|
||||||
|
if (push) {
|
||||||
|
nameTd.colSpan = 3;
|
||||||
|
link.classList.add('push');
|
||||||
|
} else {
|
||||||
|
const href = new URL(location.href);
|
||||||
|
const hash = new URLSearchParams(href.hash.replace(/^#!/, ''));
|
||||||
|
hash.set('path', path.join(this.path, name));
|
||||||
|
href.hash = `#!${hash.toString()}`;
|
||||||
|
link.href = href.toString();
|
||||||
|
const sizeTd = document.createElement('td');
|
||||||
|
sizeTd.classList.add('entry-size');
|
||||||
|
sizeTd.innerText = size;
|
||||||
|
row.appendChild(sizeTd);
|
||||||
|
const mtimeTd = document.createElement('td');
|
||||||
|
mtimeTd.classList.add('entry-time');
|
||||||
|
mtimeTd.innerText = date;
|
||||||
|
row.appendChild(mtimeTd);
|
||||||
|
}
|
||||||
|
if (push || !this.tableBody.children.length) {
|
||||||
|
this.tableBody.insertBefore(row, this.tableBody.firstChild);
|
||||||
|
} else {
|
||||||
|
this.tableBody.appendChild(row);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected finishDownload(channel: Multiplexer): void {
|
||||||
|
const download = this.downloads.get(channel);
|
||||||
|
if (!download) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.downloads.delete(channel);
|
||||||
|
const el = download.progressEl;
|
||||||
|
if (el) {
|
||||||
|
this.cleanProgress(el);
|
||||||
|
}
|
||||||
|
let name: string;
|
||||||
|
if (download.entry && download.entry.isFile()) {
|
||||||
|
name = download.entry.name;
|
||||||
|
} else {
|
||||||
|
// we always should have `download.entry` and never be here
|
||||||
|
name = path.basename(this.path);
|
||||||
|
}
|
||||||
|
if (download.pathToLoadAfter) {
|
||||||
|
this.channels.delete(channel);
|
||||||
|
this.loadContent(download.pathToLoadAfter);
|
||||||
|
}
|
||||||
|
const file = new File(download.chunks, name, { type: 'application/octet-stream' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(file);
|
||||||
|
a.download = `${name}`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cleanProgress(el: HTMLElement): void {
|
||||||
|
el.classList.add('finished');
|
||||||
|
setTimeout(() => {
|
||||||
|
const parent = el.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.removeChild(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPath(): string {
|
||||||
|
return this.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reload(): void {
|
||||||
|
this.loadContent(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected supportMultiplexing(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelInitData(): Buffer {
|
||||||
|
const serial = Util.stringToUtf8ByteArray(this.serial);
|
||||||
|
const buffer = Buffer.alloc(4 + 4 + serial.byteLength);
|
||||||
|
buffer.write(ChannelCode.FSLS, 'ascii');
|
||||||
|
buffer.writeUInt32LE(serial.length, 4);
|
||||||
|
buffer.set(serial, 8);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/app/googDevice/client/ShellClient.ts
Normal file
147
src/app/googDevice/client/ShellClient.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import 'xterm/css/xterm.css';
|
||||||
|
import { ManagerClient } from '../../client/ManagerClient';
|
||||||
|
import { Terminal } from 'xterm';
|
||||||
|
import { AttachAddon } from 'xterm-addon-attach';
|
||||||
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
|
import { MessageXtermClient } from '../../../types/MessageXtermClient';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import { ParamsShell } from '../../../types/ParamsShell';
|
||||||
|
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
|
||||||
|
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
|
||||||
|
import Util from '../../Util';
|
||||||
|
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
|
||||||
|
import { ChannelCode } from '../../../common/ChannelCode';
|
||||||
|
|
||||||
|
const TAG = '[ShellClient]';
|
||||||
|
|
||||||
|
export class ShellClient extends ManagerClient<ParamsShell, never> {
|
||||||
|
public static ACTION = ACTION.SHELL;
|
||||||
|
public static start(params: ParamsShell): ShellClient {
|
||||||
|
return new ShellClient(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly term: Terminal;
|
||||||
|
private readonly fitAddon: FitAddon;
|
||||||
|
private readonly escapedUdid: string;
|
||||||
|
private readonly udid: string;
|
||||||
|
|
||||||
|
constructor(params: ParamsShell) {
|
||||||
|
super(params);
|
||||||
|
this.udid = params.udid;
|
||||||
|
this.openNewConnection();
|
||||||
|
this.setTitle(`Shell ${this.udid}`);
|
||||||
|
this.setBodyClass('shell');
|
||||||
|
if (!this.ws) {
|
||||||
|
throw Error('No WebSocket');
|
||||||
|
}
|
||||||
|
this.term = new Terminal();
|
||||||
|
this.term.loadAddon(new AttachAddon(this.ws));
|
||||||
|
this.fitAddon = new FitAddon();
|
||||||
|
this.term.loadAddon(this.fitAddon);
|
||||||
|
this.escapedUdid = Util.escapeUdid(this.udid);
|
||||||
|
this.term.open(ShellClient.getOrCreateContainer(this.escapedUdid));
|
||||||
|
this.updateTerminalSize();
|
||||||
|
this.term.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected supportMultiplexing(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseParameters(params: URLSearchParams): ParamsShell {
|
||||||
|
const typedParams = super.parseParameters(params);
|
||||||
|
const { action } = typedParams;
|
||||||
|
if (action !== ACTION.SHELL) {
|
||||||
|
throw Error('Incorrect action');
|
||||||
|
}
|
||||||
|
return { ...typedParams, action, udid: Util.parseString(params, 'udid', true) };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketOpen = (): void => {
|
||||||
|
this.startShell(this.udid);
|
||||||
|
};
|
||||||
|
|
||||||
|
protected onSocketClose(event: CloseEvent): void {
|
||||||
|
console.log(TAG, `Connection closed: ${event.reason}`);
|
||||||
|
this.term.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSocketMessage(): void {
|
||||||
|
// messages are processed by Attach Addon
|
||||||
|
}
|
||||||
|
|
||||||
|
public startShell(udid: string): void {
|
||||||
|
if (!udid || !this.ws || this.ws.readyState !== this.ws.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { rows, cols } = this.fitAddon.proposeDimensions();
|
||||||
|
const message: MessageXtermClient = {
|
||||||
|
id: 1,
|
||||||
|
type: 'shell',
|
||||||
|
data: {
|
||||||
|
type: 'start',
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
udid,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
console.log('发送的参数5');
|
||||||
|
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getOrCreateContainer(udid: string): HTMLElement {
|
||||||
|
let container = document.getElementById(udid);
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'terminal-container';
|
||||||
|
container.id = udid;
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTerminalSize(): void {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const term: any = this.term;
|
||||||
|
const terminalContainer: HTMLElement = ShellClient.getOrCreateContainer(this.escapedUdid);
|
||||||
|
const { rows, cols } = this.fitAddon.proposeDimensions();
|
||||||
|
const width =
|
||||||
|
(cols * term._core._renderService.dimensions.actualCellWidth + term._core.viewport.scrollBarWidth).toFixed(
|
||||||
|
2,
|
||||||
|
) + 'px';
|
||||||
|
const height = (rows * term._core._renderService.dimensions.actualCellHeight).toFixed(2) + 'px';
|
||||||
|
terminalContainer.style.width = width;
|
||||||
|
terminalContainer.style.height = height;
|
||||||
|
this.fitAddon.fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createEntryForDeviceList(
|
||||||
|
descriptor: GoogDeviceDescriptor,
|
||||||
|
blockClass: string,
|
||||||
|
params: ParamsDeviceTracker,
|
||||||
|
): HTMLElement | DocumentFragment | undefined {
|
||||||
|
if (descriptor.state !== 'device') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.classList.add('shell', blockClass);
|
||||||
|
entry.appendChild(
|
||||||
|
BaseDeviceTracker.buildLink(
|
||||||
|
{
|
||||||
|
action: ACTION.SHELL,
|
||||||
|
udid: descriptor.udid,
|
||||||
|
},
|
||||||
|
'shell',
|
||||||
|
params,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getChannelInitData(): Buffer {
|
||||||
|
const buffer = Buffer.alloc(4);
|
||||||
|
buffer.write(ChannelCode.SHEL, 'ascii');
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
506
src/app/googDevice/client/StreamClientScrcpy.ts
Normal file
506
src/app/googDevice/client/StreamClientScrcpy.ts
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import { BaseClient } from '../../client/BaseClient';
|
||||||
|
import { ParamsStreamScrcpy } from '../../../types/ParamsStreamScrcpy';
|
||||||
|
import { GoogMoreBox } from '../toolbox/GoogMoreBox';
|
||||||
|
import { GoogToolBox } from '../toolbox/GoogToolBox';
|
||||||
|
import VideoSettings from '../../VideoSettings';
|
||||||
|
import Size from '../../Size';
|
||||||
|
import { ControlMessage } from '../../controlMessage/ControlMessage';
|
||||||
|
import { ClientsStats, DisplayCombinedInfo } from '../../client/StreamReceiver';
|
||||||
|
import { CommandControlMessage } from '../../controlMessage/CommandControlMessage';
|
||||||
|
import Util from '../../Util';
|
||||||
|
import FilePushHandler from '../filePush/FilePushHandler';
|
||||||
|
import DragAndPushLogger from '../DragAndPushLogger';
|
||||||
|
import { KeyEventListener, KeyInputHandler } from '../KeyInputHandler';
|
||||||
|
import { KeyCodeControlMessage } from '../../controlMessage/KeyCodeControlMessage';
|
||||||
|
import { BasePlayer, PlayerClass } from '../../player/BasePlayer';
|
||||||
|
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
|
||||||
|
import { ConfigureScrcpy } from './ConfigureScrcpy';
|
||||||
|
import { DeviceTracker } from './DeviceTracker';
|
||||||
|
import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
|
||||||
|
import { html } from '../../ui/HtmlTag';
|
||||||
|
import {
|
||||||
|
FeaturedInteractionHandler,
|
||||||
|
InteractionHandlerListener,
|
||||||
|
} from '../../interactionHandler/FeaturedInteractionHandler';
|
||||||
|
import DeviceMessage from '../DeviceMessage';
|
||||||
|
import { DisplayInfo } from '../../DisplayInfo';
|
||||||
|
import { Attribute } from '../../Attribute';
|
||||||
|
import { HostTracker } from '../../client/HostTracker';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import { StreamReceiverScrcpy } from './StreamReceiverScrcpy';
|
||||||
|
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
|
||||||
|
import { ScrcpyFilePushStream } from '../filePush/ScrcpyFilePushStream';
|
||||||
|
|
||||||
|
type StartParams = {
|
||||||
|
udid: string;
|
||||||
|
playerName?: string;
|
||||||
|
player?: BasePlayer;
|
||||||
|
fitToScreen?: boolean;
|
||||||
|
videoSettings?: VideoSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAG = '[StreamClientScrcpy]';
|
||||||
|
|
||||||
|
export class StreamClientScrcpy
|
||||||
|
extends BaseClient<ParamsStreamScrcpy, never>
|
||||||
|
implements KeyEventListener, InteractionHandlerListener {
|
||||||
|
public static ACTION = 'stream';
|
||||||
|
private static players: Map<string, PlayerClass> = new Map<string, PlayerClass>();
|
||||||
|
|
||||||
|
private controlButtons?: HTMLElement;
|
||||||
|
private deviceName = '';
|
||||||
|
private clientId = -1;
|
||||||
|
private clientsCount = -1;
|
||||||
|
private joinedStream = false;
|
||||||
|
private requestedVideoSettings?: VideoSettings;
|
||||||
|
private touchHandler?: FeaturedInteractionHandler;
|
||||||
|
private moreBox?: GoogMoreBox;
|
||||||
|
private player?: BasePlayer;
|
||||||
|
private filePushHandler?: FilePushHandler;
|
||||||
|
private fitToScreen?: boolean;
|
||||||
|
private readonly streamReceiver: StreamReceiverScrcpy;
|
||||||
|
|
||||||
|
public static registerPlayer(playerClass: PlayerClass): void {
|
||||||
|
if (playerClass.isSupported()) {
|
||||||
|
this.players.set(playerClass.playerFullName, playerClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getPlayers(): PlayerClass[] {
|
||||||
|
return Array.from(this.players.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getPlayerClass(playerName: string): PlayerClass | undefined {
|
||||||
|
let playerClass: PlayerClass | undefined;
|
||||||
|
for (const value of StreamClientScrcpy.players.values()) {
|
||||||
|
if (value.playerFullName === playerName || value.playerCodeName === playerName) {
|
||||||
|
playerClass = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return playerClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createPlayer(playerName: string, udid: string, displayInfo?: DisplayInfo): BasePlayer | undefined {
|
||||||
|
const playerClass = this.getPlayerClass(playerName);
|
||||||
|
if (!playerClass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return new playerClass(udid, displayInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getFitToScreen(playerName: string, udid: string, displayInfo?: DisplayInfo): boolean {
|
||||||
|
const playerClass = this.getPlayerClass(playerName);
|
||||||
|
if (!playerClass) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return playerClass.getFitToScreenStatus(udid, displayInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static start(
|
||||||
|
query: URLSearchParams | ParamsStreamScrcpy,
|
||||||
|
streamReceiver?: StreamReceiverScrcpy,
|
||||||
|
player?: BasePlayer,
|
||||||
|
fitToScreen?: boolean,
|
||||||
|
videoSettings?: VideoSettings,
|
||||||
|
): StreamClientScrcpy {
|
||||||
|
if (query instanceof URLSearchParams) {
|
||||||
|
const params = StreamClientScrcpy.parseParameters(query);
|
||||||
|
return new StreamClientScrcpy(params, streamReceiver, player, fitToScreen, videoSettings);
|
||||||
|
} else {
|
||||||
|
return new StreamClientScrcpy(query, streamReceiver, player, fitToScreen, videoSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createVideoSettingsWithBounds(old: VideoSettings, newBounds: Size): VideoSettings {
|
||||||
|
return new VideoSettings({
|
||||||
|
crop: old.crop,
|
||||||
|
bitrate: old.bitrate,
|
||||||
|
bounds: newBounds,
|
||||||
|
maxFps: old.maxFps,
|
||||||
|
iFrameInterval: old.iFrameInterval,
|
||||||
|
sendFrameMeta: old.sendFrameMeta,
|
||||||
|
lockedVideoOrientation: old.lockedVideoOrientation,
|
||||||
|
displayId: old.displayId,
|
||||||
|
codecOptions: old.codecOptions,
|
||||||
|
encoderName: old.encoderName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected constructor(
|
||||||
|
params: ParamsStreamScrcpy,
|
||||||
|
streamReceiver?: StreamReceiverScrcpy,
|
||||||
|
player?: BasePlayer,
|
||||||
|
fitToScreen?: boolean,
|
||||||
|
videoSettings?: VideoSettings,
|
||||||
|
) {
|
||||||
|
super(params);
|
||||||
|
if (streamReceiver) {
|
||||||
|
this.streamReceiver = streamReceiver;
|
||||||
|
} else {
|
||||||
|
this.streamReceiver = new StreamReceiverScrcpy(this.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { udid, player: playerName } = this.params;
|
||||||
|
this.startStream({ udid, player, playerName, fitToScreen, videoSettings });
|
||||||
|
this.setBodyClass('stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parseParameters(params: URLSearchParams): ParamsStreamScrcpy {
|
||||||
|
const typedParams = super.parseParameters(params);
|
||||||
|
const { action } = typedParams;
|
||||||
|
if (action !== ACTION.STREAM_SCRCPY) {
|
||||||
|
throw Error('Incorrect action');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...typedParams,
|
||||||
|
action,
|
||||||
|
player: Util.parseString(params, 'player', true),
|
||||||
|
udid: Util.parseString(params, 'udid', true),
|
||||||
|
ws: Util.parseString(params, 'ws', true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public OnDeviceMessage = (message: DeviceMessage): void => {
|
||||||
|
if (this.moreBox) {
|
||||||
|
console.log('message', message)
|
||||||
|
this.moreBox.OnDeviceMessage(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public onVideo = (data: ArrayBuffer): void => {
|
||||||
|
|
||||||
|
if (!this.player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const STATE = BasePlayer.STATE;
|
||||||
|
if (this.player.getState() === STATE.PAUSED) {
|
||||||
|
this.player.play();
|
||||||
|
}
|
||||||
|
if (this.player.getState() === STATE.PLAYING) {
|
||||||
|
this.player.pushFrame(new Uint8Array(data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public onClientsStats = (stats: ClientsStats): void => {
|
||||||
|
this.deviceName = stats.deviceName;
|
||||||
|
this.clientId = stats.clientId;
|
||||||
|
this.setTitle(`Stream ${this.deviceName}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
public onDisplayInfo = (infoArray: DisplayCombinedInfo[]): void => {
|
||||||
|
if (!this.player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let currentSettings = this.player.getVideoSettings();
|
||||||
|
const displayId = currentSettings.displayId;
|
||||||
|
const info = infoArray.find((value) => {
|
||||||
|
return value.displayInfo.displayId === displayId;
|
||||||
|
});
|
||||||
|
if (!info) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.player.getState() === BasePlayer.STATE.PAUSED) {
|
||||||
|
this.player.play();
|
||||||
|
}
|
||||||
|
const { videoSettings, screenInfo } = info;
|
||||||
|
this.player.setDisplayInfo(info.displayInfo);
|
||||||
|
if (typeof this.fitToScreen !== 'boolean') {
|
||||||
|
this.fitToScreen = this.player.getFitToScreenStatus();
|
||||||
|
}
|
||||||
|
if (this.fitToScreen) {
|
||||||
|
const newBounds = this.getMaxSize();
|
||||||
|
if (newBounds) {
|
||||||
|
currentSettings = StreamClientScrcpy.createVideoSettingsWithBounds(currentSettings, newBounds);
|
||||||
|
this.player.setVideoSettings(currentSettings, this.fitToScreen, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!videoSettings || !screenInfo) {
|
||||||
|
this.joinedStream = true;
|
||||||
|
this.sendMessage(CommandControlMessage.createSetVideoSettingsCommand(currentSettings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clientsCount = info.connectionCount;
|
||||||
|
let min = VideoSettings.copy(videoSettings);
|
||||||
|
const oldInfo = this.player.getScreenInfo();
|
||||||
|
if (!screenInfo.equals(oldInfo)) {
|
||||||
|
this.player.setScreenInfo(screenInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoSettings.equals(currentSettings)) {
|
||||||
|
this.applyNewVideoSettings(videoSettings, videoSettings.equals(this.requestedVideoSettings));
|
||||||
|
}
|
||||||
|
if (!oldInfo) {
|
||||||
|
const bounds = currentSettings.bounds;
|
||||||
|
const videoSize: Size = screenInfo.videoSize;
|
||||||
|
const onlyOneClient = this.clientsCount === 0;
|
||||||
|
const smallerThenCurrent = bounds && (bounds.width < videoSize.width || bounds.height < videoSize.height);
|
||||||
|
if (onlyOneClient || smallerThenCurrent) {
|
||||||
|
min = currentSettings;
|
||||||
|
}
|
||||||
|
const minBounds = currentSettings.bounds?.intersect(min.bounds);
|
||||||
|
if (minBounds && !minBounds.equals(min.bounds)) {
|
||||||
|
min = StreamClientScrcpy.createVideoSettingsWithBounds(min, minBounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!min.equals(videoSettings) || !this.joinedStream) {
|
||||||
|
this.joinedStream = true;
|
||||||
|
this.sendMessage(CommandControlMessage.createSetVideoSettingsCommand(min));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public onDisconnected = (): void => {
|
||||||
|
this.streamReceiver.off('deviceMessage', this.OnDeviceMessage);
|
||||||
|
this.streamReceiver.off('video', this.onVideo);
|
||||||
|
this.streamReceiver.off('clientsStats', this.onClientsStats);
|
||||||
|
this.streamReceiver.off('displayInfo', this.onDisplayInfo);
|
||||||
|
this.streamReceiver.off('disconnected', this.onDisconnected);
|
||||||
|
|
||||||
|
this.filePushHandler?.release();
|
||||||
|
this.filePushHandler = undefined;
|
||||||
|
this.touchHandler?.release();
|
||||||
|
this.touchHandler = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
public startStream({ udid, player, playerName, videoSettings, fitToScreen }: StartParams): void {
|
||||||
|
if (!udid) {
|
||||||
|
throw Error(`Invalid udid value: "${udid}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fitToScreen = fitToScreen;
|
||||||
|
if (!player) {
|
||||||
|
if (typeof playerName !== 'string') {
|
||||||
|
throw Error('Must provide BasePlayer instance or playerName');
|
||||||
|
}
|
||||||
|
let displayInfo: DisplayInfo | undefined;
|
||||||
|
if (this.streamReceiver && videoSettings) {
|
||||||
|
displayInfo = this.streamReceiver.getDisplayInfo(videoSettings.displayId);
|
||||||
|
}
|
||||||
|
const p = StreamClientScrcpy.createPlayer(playerName, udid, displayInfo);
|
||||||
|
if (!p) {
|
||||||
|
throw Error(`Unsupported player: "${playerName}"`);
|
||||||
|
}
|
||||||
|
if (typeof fitToScreen !== 'boolean') {
|
||||||
|
fitToScreen = StreamClientScrcpy.getFitToScreen(playerName, udid, displayInfo);
|
||||||
|
}
|
||||||
|
player = p;
|
||||||
|
}
|
||||||
|
this.player = player;
|
||||||
|
this.setTouchListeners(player);
|
||||||
|
|
||||||
|
if (!videoSettings) {
|
||||||
|
videoSettings = player.getVideoSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceView = document.createElement('div');
|
||||||
|
deviceView.className = 'device-view';
|
||||||
|
const stop = (ev?: string | Event) => {
|
||||||
|
if (ev && ev instanceof Event && ev.type === 'error') {
|
||||||
|
console.error(TAG, ev);
|
||||||
|
}
|
||||||
|
let parent;
|
||||||
|
parent = deviceView.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.removeChild(deviceView);
|
||||||
|
}
|
||||||
|
parent = moreBox.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.removeChild(moreBox);
|
||||||
|
}
|
||||||
|
this.streamReceiver.stop();
|
||||||
|
if (this.player) {
|
||||||
|
this.player.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const googMoreBox = (this.moreBox = new GoogMoreBox(udid, player, this));
|
||||||
|
const moreBox = googMoreBox.getHolderElement();
|
||||||
|
googMoreBox.setOnStop(stop);
|
||||||
|
const googToolBox = GoogToolBox.createToolBox(udid, player, this, moreBox);
|
||||||
|
this.controlButtons = googToolBox.getHolderElement();
|
||||||
|
deviceView.appendChild(this.controlButtons);
|
||||||
|
const video = document.createElement('div');
|
||||||
|
video.className = 'video';
|
||||||
|
deviceView.appendChild(video);
|
||||||
|
deviceView.appendChild(moreBox);
|
||||||
|
player.setParent(video);
|
||||||
|
player.pause();
|
||||||
|
|
||||||
|
document.body.appendChild(deviceView);
|
||||||
|
if (fitToScreen) {
|
||||||
|
const newBounds = this.getMaxSize();
|
||||||
|
if (newBounds) {
|
||||||
|
videoSettings = StreamClientScrcpy.createVideoSettingsWithBounds(videoSettings, newBounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.applyNewVideoSettings(videoSettings, false);
|
||||||
|
const element = player.getTouchableElement();
|
||||||
|
const logger = new DragAndPushLogger(element);
|
||||||
|
this.filePushHandler = new FilePushHandler(element, new ScrcpyFilePushStream(this.streamReceiver));
|
||||||
|
this.filePushHandler.addEventListener(logger);
|
||||||
|
|
||||||
|
const streamReceiver = this.streamReceiver;
|
||||||
|
streamReceiver.on('deviceMessage', this.OnDeviceMessage);
|
||||||
|
streamReceiver.on('video', this.onVideo);
|
||||||
|
streamReceiver.on('clientsStats', this.onClientsStats);
|
||||||
|
streamReceiver.on('displayInfo', this.onDisplayInfo);
|
||||||
|
streamReceiver.on('disconnected', this.onDisconnected);
|
||||||
|
console.log(TAG, player.getName(), udid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendMessage(message: ControlMessage): void {
|
||||||
|
console.log("发送消息", message)
|
||||||
|
this.streamReceiver.sendEvent(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDeviceName(): string {
|
||||||
|
return this.deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setHandleKeyboardEvents(enabled: boolean): void {
|
||||||
|
if (enabled) {
|
||||||
|
KeyInputHandler.addEventListener(this);
|
||||||
|
} else {
|
||||||
|
KeyInputHandler.removeEventListener(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onKeyEvent(event: KeyCodeControlMessage): void {
|
||||||
|
this.sendMessage(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendNewVideoSetting(videoSettings: VideoSettings): void {
|
||||||
|
this.requestedVideoSettings = videoSettings;
|
||||||
|
this.sendMessage(CommandControlMessage.createSetVideoSettingsCommand(videoSettings));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getClientId(): number {
|
||||||
|
return this.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getClientsCount(): number {
|
||||||
|
return this.clientsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMaxSize(): Size | undefined {
|
||||||
|
if (!this.controlButtons) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = document.body;
|
||||||
|
const width = (body.clientWidth - this.controlButtons.clientWidth) & ~15;
|
||||||
|
const height = body.clientHeight & ~15;
|
||||||
|
return new Size(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setTouchListeners(player: BasePlayer): void {
|
||||||
|
if (this.touchHandler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.touchHandler = new FeaturedInteractionHandler(player, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyNewVideoSettings(videoSettings: VideoSettings, saveToStorage: boolean): void {
|
||||||
|
let fitToScreen = false;
|
||||||
|
|
||||||
|
// TODO: create control (switch/checkbox) instead
|
||||||
|
if (videoSettings.bounds && videoSettings.bounds.equals(this.getMaxSize())) {
|
||||||
|
fitToScreen = true;
|
||||||
|
}
|
||||||
|
if (this.player) {
|
||||||
|
this.player.setVideoSettings(videoSettings, fitToScreen, saveToStorage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createEntryForDeviceList(
|
||||||
|
descriptor: GoogDeviceDescriptor,
|
||||||
|
blockClass: string,
|
||||||
|
fullName: string,
|
||||||
|
params: ParamsDeviceTracker,
|
||||||
|
): HTMLElement | DocumentFragment | undefined {
|
||||||
|
const hasPid = descriptor.pid !== -1;
|
||||||
|
if (hasPid) {
|
||||||
|
const configureButtonId = `configure_${Util.escapeUdid(descriptor.udid)}`;
|
||||||
|
const e = html`<div class="stream ${blockClass}">
|
||||||
|
<button
|
||||||
|
${Attribute.UDID}="${descriptor.udid}"
|
||||||
|
${Attribute.COMMAND}="${ControlCenterCommand.CONFIGURE_STREAM}"
|
||||||
|
${Attribute.FULL_NAME}="${fullName}"
|
||||||
|
${Attribute.SECURE}="${params.secure}"
|
||||||
|
${Attribute.HOSTNAME}="${params.hostname}"
|
||||||
|
${Attribute.PORT}="${params.port}"
|
||||||
|
${Attribute.PATHNAME}="${params.pathname}"
|
||||||
|
${Attribute.USE_PROXY}="${params.useProxy}"
|
||||||
|
id="${configureButtonId}"
|
||||||
|
class="active action-button"
|
||||||
|
>
|
||||||
|
Configure stream
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
const a = e.content.getElementById(configureButtonId);
|
||||||
|
a && (a.onclick = this.onConfigureStreamClick);
|
||||||
|
return e.content;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static onConfigureStreamClick = (event: MouseEvent): void => {
|
||||||
|
const button = event.currentTarget as HTMLAnchorElement;
|
||||||
|
const udid = Util.parseStringEnv(button.getAttribute(Attribute.UDID) || '');
|
||||||
|
const fullName = button.getAttribute(Attribute.FULL_NAME);
|
||||||
|
const secure = Util.parseBooleanEnv(button.getAttribute(Attribute.SECURE) || undefined) || false;
|
||||||
|
const hostname = Util.parseStringEnv(button.getAttribute(Attribute.HOSTNAME) || undefined) || '';
|
||||||
|
const port = Util.parseIntEnv(button.getAttribute(Attribute.PORT) || undefined);
|
||||||
|
const pathname = Util.parseStringEnv(button.getAttribute(Attribute.PATHNAME) || undefined) || '';
|
||||||
|
const useProxy = Util.parseBooleanEnv(button.getAttribute(Attribute.USE_PROXY) || undefined);
|
||||||
|
if (!udid) {
|
||||||
|
throw Error(`Invalid udid value: "${udid}"`);
|
||||||
|
}
|
||||||
|
if (typeof port !== 'number') {
|
||||||
|
throw Error(`Invalid port type: ${typeof port}`);
|
||||||
|
}
|
||||||
|
const tracker = DeviceTracker.getInstance({
|
||||||
|
type: 'android',
|
||||||
|
secure,
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
pathname,
|
||||||
|
useProxy,
|
||||||
|
});
|
||||||
|
const descriptor = tracker.getDescriptorByUdid(udid);
|
||||||
|
if (!descriptor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const elements = document.getElementsByName(`${DeviceTracker.AttributePrefixInterfaceSelectFor}${fullName}`);
|
||||||
|
if (!elements || !elements.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const select = elements[0] as HTMLSelectElement;
|
||||||
|
const optionElement = select.options[select.selectedIndex];
|
||||||
|
const ws = optionElement.getAttribute(Attribute.URL);
|
||||||
|
const name = optionElement.getAttribute(Attribute.NAME);
|
||||||
|
if (!ws || !name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options: ParamsStreamScrcpy = {
|
||||||
|
udid,
|
||||||
|
ws,
|
||||||
|
player: '',
|
||||||
|
action: ACTION.STREAM_SCRCPY,
|
||||||
|
secure,
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
pathname,
|
||||||
|
useProxy,
|
||||||
|
};
|
||||||
|
const dialog = new ConfigureScrcpy(tracker, descriptor, options);
|
||||||
|
dialog.on('closed', StreamClientScrcpy.onConfigureDialogClosed);
|
||||||
|
};
|
||||||
|
|
||||||
|
private static onConfigureDialogClosed = (event: { dialog: ConfigureScrcpy; result: boolean }): void => {
|
||||||
|
event.dialog.off('closed', StreamClientScrcpy.onConfigureDialogClosed);
|
||||||
|
if (event.result) {
|
||||||
|
HostTracker.getInstance().destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
24
src/app/googDevice/client/StreamReceiverScrcpy.ts
Normal file
24
src/app/googDevice/client/StreamReceiverScrcpy.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { StreamReceiver } from '../../client/StreamReceiver';
|
||||||
|
import { ParamsStreamScrcpy } from '../../../types/ParamsStreamScrcpy';
|
||||||
|
import { ACTION } from '../../../common/Action';
|
||||||
|
import Util from '../../Util';
|
||||||
|
|
||||||
|
export class StreamReceiverScrcpy extends StreamReceiver<ParamsStreamScrcpy> {
|
||||||
|
public static parseParameters(params: URLSearchParams): ParamsStreamScrcpy {
|
||||||
|
const typedParams = super.parseParameters(params);
|
||||||
|
const { action } = typedParams;
|
||||||
|
if (action !== ACTION.STREAM_SCRCPY) {
|
||||||
|
throw Error('Incorrect action');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...typedParams,
|
||||||
|
action,
|
||||||
|
udid: Util.parseString(params, 'udid', true),
|
||||||
|
ws: Util.parseString(params, 'ws', true),
|
||||||
|
player: Util.parseString(params, 'player', true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
protected buildDirectWebSocketUrl(): URL {
|
||||||
|
return new URL((this.params as ParamsStreamScrcpy).ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/app/googDevice/filePush/AdbkitFilePushStream.ts
Normal file
110
src/app/googDevice/filePush/AdbkitFilePushStream.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { FilePushStream } from './FilePushStream';
|
||||||
|
import { CommandControlMessage, FilePushState } from '../../controlMessage/CommandControlMessage';
|
||||||
|
import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
|
||||||
|
import { FilePushResponseStatus } from './FilePushResponseStatus';
|
||||||
|
import Protocol from '@dead50f7/adbkit/lib/adb/protocol';
|
||||||
|
import { FileListingClient } from '../client/FileListingClient';
|
||||||
|
import * as path from 'path';
|
||||||
|
import FilePushHandler from './FilePushHandler';
|
||||||
|
|
||||||
|
export class AdbkitFilePushStream extends FilePushStream {
|
||||||
|
private channels: Map<number, Multiplexer> = new Map();
|
||||||
|
constructor(private readonly socket: Multiplexer, private readonly fileListingClient: FileListingClient) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
public hasConnection(): boolean {
|
||||||
|
return this.socket.readyState == this.socket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isAllowedFile(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChannel(id: number): Multiplexer | undefined {
|
||||||
|
const channel = this.channels.get(id);
|
||||||
|
let code: FilePushResponseStatus = FilePushResponseStatus.NO_ERROR;
|
||||||
|
if (!channel) {
|
||||||
|
code = FilePushResponseStatus.ERROR_UNKNOWN_ID;
|
||||||
|
}
|
||||||
|
if (code) {
|
||||||
|
this.emit('response', { id, code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendEventAppend({ id, chunk }: { id: number; chunk: Uint8Array }): void {
|
||||||
|
const appendParams = { id, chunk, state: FilePushState.APPEND };
|
||||||
|
const channel = this.getChannel(id);
|
||||||
|
if (!channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('发送的参数6');
|
||||||
|
|
||||||
|
channel.send(CommandControlMessage.createPushFileCommand(appendParams).toBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendEventFinish({ id }: { id: number }): void {
|
||||||
|
const finishParams = { id, state: FilePushState.FINISH };
|
||||||
|
const channel = this.getChannel(id);
|
||||||
|
if (!channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('发送的参数7');
|
||||||
|
|
||||||
|
channel.send(CommandControlMessage.createPushFileCommand(finishParams).toBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendEventNew({ id }: { id: number }): void {
|
||||||
|
let pushId = id;
|
||||||
|
const newParams = { id, state: FilePushState.NEW };
|
||||||
|
const channel = this.socket.createChannel(Buffer.from(Protocol.SEND));
|
||||||
|
const onMessage = (event: MessageEvent): void => {
|
||||||
|
let offset = 0;
|
||||||
|
const buffer = Buffer.from(event.data);
|
||||||
|
const id = buffer.readInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const code = buffer.readInt8(offset);
|
||||||
|
if (code === FilePushResponseStatus.NEW_PUSH_ID) {
|
||||||
|
this.channels.set(id, channel);
|
||||||
|
pushId = id;
|
||||||
|
}
|
||||||
|
this.emit('response', { id, code });
|
||||||
|
};
|
||||||
|
const onClose = (event: CloseEvent): void => {
|
||||||
|
if (!event.wasClean) {
|
||||||
|
const code = 4000 - event.code;
|
||||||
|
// this.emit('response', { id: pushId, code });
|
||||||
|
this.emit('error', {
|
||||||
|
id: pushId,
|
||||||
|
error: new Error(FilePushHandler.getErrorMessage(code, event.reason)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
channel.removeEventListener('message', onMessage);
|
||||||
|
channel.removeEventListener('close', onClose);
|
||||||
|
};
|
||||||
|
channel.addEventListener('message', onMessage);
|
||||||
|
channel.addEventListener('close', onClose);
|
||||||
|
console.log('发送的参数7');
|
||||||
|
|
||||||
|
channel.send(CommandControlMessage.createPushFileCommand(newParams).toBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendEventStart({ id, fileName, fileSize }: { id: number; fileName: string; fileSize: number }): void {
|
||||||
|
const filePath = path.join(this.fileListingClient.getPath(), fileName);
|
||||||
|
const startParams = { id, fileName: filePath, fileSize, state: FilePushState.START };
|
||||||
|
const channel = this.getChannel(id);
|
||||||
|
if (!channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('发送的参数8');
|
||||||
|
|
||||||
|
channel.send(CommandControlMessage.createPushFileCommand(startParams).toBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
public release(): void {
|
||||||
|
this.channels.forEach((channel) => {
|
||||||
|
channel.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/app/googDevice/filePush/FilePushHandler.ts
Normal file
256
src/app/googDevice/filePush/FilePushHandler.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { DragAndDropHandler, DragEventListener } from '../DragAndDropHandler';
|
||||||
|
import { FilePushStream, PushResponse } from './FilePushStream';
|
||||||
|
import { FilePushResponseStatus } from './FilePushResponseStatus';
|
||||||
|
|
||||||
|
type Resolve = (response: PushResponse) => void;
|
||||||
|
|
||||||
|
export type PushUpdateParams = {
|
||||||
|
pushId: number;
|
||||||
|
fileName: string;
|
||||||
|
message: string;
|
||||||
|
progress: number;
|
||||||
|
error: boolean;
|
||||||
|
finished: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DragAndPushListener {
|
||||||
|
onDragEnter: () => boolean;
|
||||||
|
onDragLeave: () => boolean;
|
||||||
|
onDrop: () => boolean;
|
||||||
|
onFilePushUpdate: (data: PushUpdateParams) => void;
|
||||||
|
onError: (error: Error | string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG = '[FilePushHandler]';
|
||||||
|
|
||||||
|
export default class FilePushHandler implements DragEventListener {
|
||||||
|
public static readonly REQUEST_NEW_PUSH_ID = 0; // ignored on server, when state is `NEW_PUSH_ID`
|
||||||
|
|
||||||
|
private responseWaiter: Map<number, Resolve | Resolve[]> = new Map();
|
||||||
|
private listeners: Set<DragAndPushListener> = new Set();
|
||||||
|
private pushIdFileNameMap: Map<number, string> = new Map();
|
||||||
|
|
||||||
|
constructor(private readonly element: HTMLElement, private readonly filePushStream: FilePushStream) {
|
||||||
|
DragAndDropHandler.addEventListener(this);
|
||||||
|
filePushStream.on('response', this.onStreamResponse);
|
||||||
|
filePushStream.on('error', this.onStreamError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendUpdate(params: PushUpdateParams): void {
|
||||||
|
if (params.error) {
|
||||||
|
this.pushIdFileNameMap.delete(params.pushId);
|
||||||
|
}
|
||||||
|
this.listeners.forEach((listener) => {
|
||||||
|
listener.onFilePushUpdate(params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private logError(pushId: number, fileName: string, code: number): void {
|
||||||
|
const msg = RESPONSE_CODES.get(code) || `Unknown error (${code})`;
|
||||||
|
this.sendUpdate({ pushId, fileName, message: `error: "${msg}"`, progress: -1, error: true, finished: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getStreamReader(file: File): Promise<{
|
||||||
|
reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
result: ReadableStreamReadResult<Uint8Array>;
|
||||||
|
}> {
|
||||||
|
const blob = await new Response(file).blob();
|
||||||
|
const reader = blob.stream().getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
const result = await reader.read();
|
||||||
|
return { reader, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pushFile(file: File): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
const { name: fileName, size: fileSize } = file;
|
||||||
|
if (!this.filePushStream.hasConnection()) {
|
||||||
|
this.listeners.forEach((listener) => {
|
||||||
|
listener.onError('WebSocket is not ready');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = FilePushHandler.REQUEST_NEW_PUSH_ID;
|
||||||
|
this.sendUpdate({ pushId: id, fileName, message: 'begins...', progress: 0, error: false, finished: false });
|
||||||
|
this.filePushStream.sendEventNew({ id });
|
||||||
|
const { code: pushId } = await this.waitForResponse(id);
|
||||||
|
if (pushId <= 0) {
|
||||||
|
return this.logError(pushId, fileName, pushId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pushIdFileNameMap.set(pushId, fileName);
|
||||||
|
const waitPromise = this.waitForResponse(pushId);
|
||||||
|
this.filePushStream.sendEventStart({ id: pushId, fileName, fileSize });
|
||||||
|
const [{ code: startResponseCode }, { reader, result }] = await Promise.all([
|
||||||
|
waitPromise,
|
||||||
|
FilePushHandler.getStreamReader(file),
|
||||||
|
]);
|
||||||
|
if (startResponseCode !== FilePushResponseStatus.NO_ERROR) {
|
||||||
|
this.logError(pushId, fileName, startResponseCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let receivedBytes = 0;
|
||||||
|
|
||||||
|
const processData = async ({ done, value }: { done: boolean; value?: Uint8Array }): Promise<void> => {
|
||||||
|
if (done || !value) {
|
||||||
|
this.filePushStream.sendEventFinish({ id: pushId });
|
||||||
|
const { code: finishResponseCode } = await this.waitForResponse(pushId);
|
||||||
|
if (finishResponseCode !== 0) {
|
||||||
|
this.logError(pushId, fileName, finishResponseCode);
|
||||||
|
} else {
|
||||||
|
this.sendUpdate({
|
||||||
|
pushId,
|
||||||
|
fileName,
|
||||||
|
message: 'success!',
|
||||||
|
progress: 100,
|
||||||
|
error: false,
|
||||||
|
finished: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(TAG, `File "${fileName}" uploaded in ${Date.now() - start}ms`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
receivedBytes += value.length;
|
||||||
|
this.filePushStream.sendEventAppend({ id: pushId, chunk: value });
|
||||||
|
|
||||||
|
const [{ code: appendResponseCode }, result] = await Promise.all([
|
||||||
|
this.waitForResponse(pushId),
|
||||||
|
reader.read(),
|
||||||
|
]);
|
||||||
|
if (appendResponseCode !== 0) {
|
||||||
|
this.logError(pushId, fileName, appendResponseCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const progress = (receivedBytes * 100) / fileSize;
|
||||||
|
const message = `${progress.toFixed(2)}%`;
|
||||||
|
this.sendUpdate({ pushId, fileName, message, progress, error: false, finished: false });
|
||||||
|
return processData(result);
|
||||||
|
};
|
||||||
|
return processData(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForResponse(pushId: number): Promise<PushResponse> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const stored = this.responseWaiter.get(pushId);
|
||||||
|
if (Array.isArray(stored)) {
|
||||||
|
stored.push(resolve);
|
||||||
|
} else if (stored) {
|
||||||
|
const arr: Resolve[] = [stored];
|
||||||
|
arr.push(resolve);
|
||||||
|
this.responseWaiter.set(pushId, arr);
|
||||||
|
} else {
|
||||||
|
this.responseWaiter.set(pushId, resolve);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onStreamError = ({ id: pushId, error }: { id: number; error: Error }): void => {
|
||||||
|
const fileName = this.pushIdFileNameMap.get(pushId) || 'Unknown file';
|
||||||
|
this.sendUpdate({ pushId, fileName, message: error.message, progress: -1, error: true, finished: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
onStreamResponse = (response: PushResponse): void => {
|
||||||
|
let func: Resolve;
|
||||||
|
let value: PushResponse;
|
||||||
|
const { code, id: idInResponse } = response;
|
||||||
|
const id = code === FilePushResponseStatus.NEW_PUSH_ID ? FilePushHandler.REQUEST_NEW_PUSH_ID : response.id;
|
||||||
|
const resolve = this.responseWaiter.get(id);
|
||||||
|
if (!resolve) {
|
||||||
|
console.warn(TAG, `Unexpected push id: "${id}", ${JSON.stringify(response)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(resolve)) {
|
||||||
|
func = resolve.shift() as Resolve;
|
||||||
|
if (!resolve.length) {
|
||||||
|
this.responseWaiter.delete(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
func = resolve;
|
||||||
|
this.responseWaiter.delete(id);
|
||||||
|
}
|
||||||
|
if (code === FilePushResponseStatus.NEW_PUSH_ID) {
|
||||||
|
value = { id, code: idInResponse };
|
||||||
|
} else {
|
||||||
|
value = { id, code: code };
|
||||||
|
}
|
||||||
|
func(value);
|
||||||
|
};
|
||||||
|
public onFilesDrop(files: File[]): boolean {
|
||||||
|
this.listeners.forEach((listener) => {
|
||||||
|
listener.onDrop();
|
||||||
|
});
|
||||||
|
files.forEach((file: File) => {
|
||||||
|
const { type, name } = file;
|
||||||
|
if (this.filePushStream.isAllowedFile(file)) {
|
||||||
|
this.pushFile(file);
|
||||||
|
} else {
|
||||||
|
const errorParams: PushUpdateParams = {
|
||||||
|
pushId: FilePushHandler.REQUEST_NEW_PUSH_ID,
|
||||||
|
fileName: name,
|
||||||
|
message: `Unsupported type "${type}"`,
|
||||||
|
progress: -1,
|
||||||
|
error: true,
|
||||||
|
finished: true,
|
||||||
|
};
|
||||||
|
this.sendUpdate(errorParams);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getErrorMessage(code: number, message?: string): string {
|
||||||
|
return message || RESPONSE_CODES.get(code) || 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragEnter(): boolean {
|
||||||
|
let handled = false;
|
||||||
|
this.listeners.forEach((listener) => {
|
||||||
|
handled = handled || listener.onDragEnter();
|
||||||
|
});
|
||||||
|
return handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragLeave(): boolean {
|
||||||
|
let handled = false;
|
||||||
|
this.listeners.forEach((listener) => {
|
||||||
|
handled = handled || listener.onDragLeave();
|
||||||
|
});
|
||||||
|
return handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getElement(): HTMLElement {
|
||||||
|
return this.element;
|
||||||
|
}
|
||||||
|
|
||||||
|
public release(): void {
|
||||||
|
this.filePushStream.off('response', this.onStreamResponse);
|
||||||
|
this.filePushStream.off('error', this.onStreamError);
|
||||||
|
this.filePushStream.release();
|
||||||
|
DragAndDropHandler.removeEventListener(this);
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public addEventListener(listener: DragAndPushListener): void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
}
|
||||||
|
public removeEventListener(listener: DragAndPushListener): void {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESPONSE_CODES = new Map([
|
||||||
|
[FilePushResponseStatus.NEW_PUSH_ID, 'New push id'],
|
||||||
|
[FilePushResponseStatus.NO_ERROR, 'No error'],
|
||||||
|
|
||||||
|
[FilePushResponseStatus.ERROR_INVALID_NAME, 'Invalid name'],
|
||||||
|
[FilePushResponseStatus.ERROR_NO_SPACE, 'No space'],
|
||||||
|
[FilePushResponseStatus.ERROR_FAILED_TO_DELETE, 'Failed to delete existing'],
|
||||||
|
[FilePushResponseStatus.ERROR_FAILED_TO_CREATE, 'Failed to create new file'],
|
||||||
|
[FilePushResponseStatus.ERROR_FILE_NOT_FOUND, 'File not found'],
|
||||||
|
[FilePushResponseStatus.ERROR_FAILED_TO_WRITE, 'Failed to write to file'],
|
||||||
|
[FilePushResponseStatus.ERROR_FILE_IS_BUSY, 'File is busy'],
|
||||||
|
[FilePushResponseStatus.ERROR_INVALID_STATE, 'Invalid state'],
|
||||||
|
[FilePushResponseStatus.ERROR_UNKNOWN_ID, 'Unknown id'],
|
||||||
|
[FilePushResponseStatus.ERROR_NO_FREE_ID, 'No free id'],
|
||||||
|
[FilePushResponseStatus.ERROR_INCORRECT_SIZE, 'Incorrect size'],
|
||||||
|
]);
|
||||||
16
src/app/googDevice/filePush/FilePushResponseStatus.ts
Normal file
16
src/app/googDevice/filePush/FilePushResponseStatus.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export enum FilePushResponseStatus {
|
||||||
|
NEW_PUSH_ID = 1,
|
||||||
|
NO_ERROR = 0,
|
||||||
|
ERROR_INVALID_NAME = -1,
|
||||||
|
ERROR_NO_SPACE = -2,
|
||||||
|
ERROR_FAILED_TO_DELETE = -3,
|
||||||
|
ERROR_FAILED_TO_CREATE = -4,
|
||||||
|
ERROR_FILE_NOT_FOUND = -5,
|
||||||
|
ERROR_FAILED_TO_WRITE = -6,
|
||||||
|
ERROR_FILE_IS_BUSY = -7,
|
||||||
|
ERROR_INVALID_STATE = -8,
|
||||||
|
ERROR_UNKNOWN_ID = -9,
|
||||||
|
ERROR_NO_FREE_ID = -10,
|
||||||
|
ERROR_INCORRECT_SIZE = -11,
|
||||||
|
ERROR_OTHER = -12,
|
||||||
|
}
|
||||||
18
src/app/googDevice/filePush/FilePushStream.ts
Normal file
18
src/app/googDevice/filePush/FilePushStream.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { TypedEmitter } from '../../../common/TypedEmitter';
|
||||||
|
|
||||||
|
export type PushResponse = { id: number; code: number };
|
||||||
|
|
||||||
|
interface FilePushStreamEvents {
|
||||||
|
response: PushResponse;
|
||||||
|
error: { id: number; error: Error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class FilePushStream extends TypedEmitter<FilePushStreamEvents> {
|
||||||
|
public abstract hasConnection(): boolean;
|
||||||
|
public abstract isAllowedFile(file: File): boolean;
|
||||||
|
public abstract sendEventNew(params: { id: number }): void;
|
||||||
|
public abstract sendEventStart(params: { id: number; fileName: string; fileSize: number }): void;
|
||||||
|
public abstract sendEventFinish(params: { id: number }): void;
|
||||||
|
public abstract sendEventAppend(params: { id: number; chunk: Uint8Array }): void;
|
||||||
|
public abstract release(): void;
|
||||||
|
}
|
||||||
54
src/app/googDevice/filePush/ScrcpyFilePushStream.ts
Normal file
54
src/app/googDevice/filePush/ScrcpyFilePushStream.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { FilePushStream } from './FilePushStream';
|
||||||
|
import { StreamReceiverScrcpy } from '../client/StreamReceiverScrcpy';
|
||||||
|
import DeviceMessage from '../DeviceMessage';
|
||||||
|
import { CommandControlMessage, FilePushState } from '../../controlMessage/CommandControlMessage';
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = ['application/vnd.android.package-archive'];
|
||||||
|
const ALLOWED_NAME_RE = /\.apk$/i;
|
||||||
|
|
||||||
|
export class ScrcpyFilePushStream extends FilePushStream {
|
||||||
|
constructor(private readonly streamReceiver: StreamReceiverScrcpy) {
|
||||||
|
super();
|
||||||
|
streamReceiver.on('deviceMessage', this.onDeviceMessage);
|
||||||
|
}
|
||||||
|
public hasConnection(): boolean {
|
||||||
|
return this.streamReceiver.hasConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public isAllowedFile(file: File): boolean {
|
||||||
|
const { type, name } = file;
|
||||||
|
return (type && ALLOWED_TYPES.includes(type)) || (!type && ALLOWED_NAME_RE.test(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendEventAppend({ id, chunk }: { id: number; chunk: Uint8Array }): void {
|
||||||
|
const appendParams = { id, chunk, state: FilePushState.APPEND };
|
||||||
|
this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(appendParams));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendEventFinish({ id }: { id: number }): void {
|
||||||
|
const finishParams = { id, state: FilePushState.FINISH };
|
||||||
|
this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(finishParams));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendEventNew({ id }: { id: number }): void {
|
||||||
|
const newParams = { id, state: FilePushState.NEW };
|
||||||
|
this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(newParams));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendEventStart({ id, fileName, fileSize }: { id: number; fileName: string; fileSize: number }): void {
|
||||||
|
const startParams = { id, fileName, fileSize, state: FilePushState.START };
|
||||||
|
this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(startParams));
|
||||||
|
}
|
||||||
|
|
||||||
|
public release(): void {
|
||||||
|
this.streamReceiver.off('deviceMessage', this.onDeviceMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeviceMessage = (ev: DeviceMessage): void => {
|
||||||
|
if (ev.type !== DeviceMessage.TYPE_PUSH_RESPONSE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stats = ev.getPushStats();
|
||||||
|
this.emit('response', stats);
|
||||||
|
};
|
||||||
|
}
|
||||||
319
src/app/googDevice/toolbox/GoogMoreBox.ts
Normal file
319
src/app/googDevice/toolbox/GoogMoreBox.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import '../../../style/morebox.css';
|
||||||
|
import { BasePlayer } from '../../player/BasePlayer';
|
||||||
|
import { TextControlMessage } from '../../controlMessage/TextControlMessage';
|
||||||
|
import { CommandControlMessage } from '../../controlMessage/CommandControlMessage';
|
||||||
|
import { ControlMessage } from '../../controlMessage/ControlMessage';
|
||||||
|
import Size from '../../Size';
|
||||||
|
import DeviceMessage from '../DeviceMessage';
|
||||||
|
import VideoSettings from '../../VideoSettings';
|
||||||
|
import { StreamClientScrcpy } from '../client/StreamClientScrcpy';
|
||||||
|
|
||||||
|
const TAG = '[GoogMoreBox]';
|
||||||
|
|
||||||
|
export class GoogMoreBox {
|
||||||
|
private static defaultSize = new Size(480, 480);
|
||||||
|
private onStop?: () => void;
|
||||||
|
private readonly holder: HTMLElement;
|
||||||
|
private readonly input: HTMLTextAreaElement;
|
||||||
|
private readonly bitrateInput?: HTMLInputElement;
|
||||||
|
private readonly maxFpsInput?: HTMLInputElement;
|
||||||
|
private readonly iFrameIntervalInput?: HTMLInputElement;
|
||||||
|
private readonly maxWidthInput?: HTMLInputElement;
|
||||||
|
private readonly maxHeightInput?: HTMLInputElement;
|
||||||
|
|
||||||
|
constructor(udid: string, private player: BasePlayer, private client: StreamClientScrcpy) {
|
||||||
|
const playerName = player.getName();
|
||||||
|
const videoSettings = player.getVideoSettings();
|
||||||
|
const { displayId } = videoSettings;
|
||||||
|
const preferredSettings = player.getPreferredVideoSetting();
|
||||||
|
const moreBox = document.createElement('div');
|
||||||
|
moreBox.className = 'more-box';
|
||||||
|
const nameBox = document.createElement('p');
|
||||||
|
nameBox.innerText = `${udid} (${playerName})`;
|
||||||
|
nameBox.className = 'text-with-shadow';
|
||||||
|
moreBox.appendChild(nameBox);
|
||||||
|
const input = (this.input = document.createElement('textarea'));
|
||||||
|
input.classList.add('text-area');
|
||||||
|
const sendButton = document.createElement('button');
|
||||||
|
sendButton.innerText = 'Send as keys';
|
||||||
|
|
||||||
|
const inputWrapper = GoogMoreBox.wrap('p', [input, sendButton], moreBox);
|
||||||
|
sendButton.onclick = () => {
|
||||||
|
if (input.value) {
|
||||||
|
client.sendMessage(new TextControlMessage(input.value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commands: HTMLElement[] = [];
|
||||||
|
const codes = CommandControlMessage.Commands;
|
||||||
|
for (const [action, command] of codes.entries()) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
let bitrateInput: HTMLInputElement;
|
||||||
|
let maxFpsInput: HTMLInputElement;
|
||||||
|
let iFrameIntervalInput: HTMLInputElement;
|
||||||
|
let maxWidthInput: HTMLInputElement;
|
||||||
|
let maxHeightInput: HTMLInputElement;
|
||||||
|
if (action === ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) {
|
||||||
|
const spoiler = document.createElement('div');
|
||||||
|
const spoilerLabel = document.createElement('label');
|
||||||
|
const spoilerCheck = document.createElement('input');
|
||||||
|
|
||||||
|
const innerDiv = document.createElement('div');
|
||||||
|
const id = `spoiler_video_${udid}_${playerName}_${displayId}_${action}`;
|
||||||
|
|
||||||
|
spoiler.className = 'spoiler';
|
||||||
|
spoilerCheck.type = 'checkbox';
|
||||||
|
spoilerCheck.id = id;
|
||||||
|
spoilerLabel.htmlFor = id;
|
||||||
|
spoilerLabel.innerText = command;
|
||||||
|
innerDiv.className = 'box';
|
||||||
|
spoiler.appendChild(spoilerCheck);
|
||||||
|
spoiler.appendChild(spoilerLabel);
|
||||||
|
spoiler.appendChild(innerDiv);
|
||||||
|
|
||||||
|
const bitrateLabel = document.createElement('label');
|
||||||
|
bitrateLabel.innerText = 'Bitrate:';
|
||||||
|
bitrateInput = document.createElement('input');
|
||||||
|
bitrateInput.placeholder = `${preferredSettings.bitrate} bps`;
|
||||||
|
bitrateInput.value = videoSettings.bitrate.toString();
|
||||||
|
GoogMoreBox.wrap('div', [bitrateLabel, bitrateInput], innerDiv);
|
||||||
|
this.bitrateInput = bitrateInput;
|
||||||
|
|
||||||
|
const maxFpsLabel = document.createElement('label');
|
||||||
|
maxFpsLabel.innerText = 'Max fps:';
|
||||||
|
maxFpsInput = document.createElement('input');
|
||||||
|
maxFpsInput.placeholder = `${preferredSettings.maxFps} fps`;
|
||||||
|
maxFpsInput.value = videoSettings.maxFps.toString();
|
||||||
|
GoogMoreBox.wrap('div', [maxFpsLabel, maxFpsInput], innerDiv);
|
||||||
|
this.maxFpsInput = maxFpsInput;
|
||||||
|
|
||||||
|
const iFrameIntervalLabel = document.createElement('label');
|
||||||
|
iFrameIntervalLabel.innerText = 'I-Frame Interval:';
|
||||||
|
iFrameIntervalInput = document.createElement('input');
|
||||||
|
iFrameIntervalInput.placeholder = `${preferredSettings.iFrameInterval} seconds`;
|
||||||
|
iFrameIntervalInput.value = videoSettings.iFrameInterval.toString();
|
||||||
|
GoogMoreBox.wrap('div', [iFrameIntervalLabel, iFrameIntervalInput], innerDiv);
|
||||||
|
this.iFrameIntervalInput = iFrameIntervalInput;
|
||||||
|
|
||||||
|
const { width, height } = videoSettings.bounds || client.getMaxSize() || GoogMoreBox.defaultSize;
|
||||||
|
const pWidth = preferredSettings.bounds?.width || width;
|
||||||
|
const pHeight = preferredSettings.bounds?.height || height;
|
||||||
|
|
||||||
|
const maxWidthLabel = document.createElement('label');
|
||||||
|
maxWidthLabel.innerText = 'Max width:';
|
||||||
|
maxWidthInput = document.createElement('input');
|
||||||
|
maxWidthInput.placeholder = `${pWidth} px`;
|
||||||
|
maxWidthInput.value = width.toString();
|
||||||
|
GoogMoreBox.wrap('div', [maxWidthLabel, maxWidthInput], innerDiv);
|
||||||
|
this.maxWidthInput = maxWidthInput;
|
||||||
|
|
||||||
|
const maxHeightLabel = document.createElement('label');
|
||||||
|
maxHeightLabel.innerText = 'Max height:';
|
||||||
|
maxHeightInput = document.createElement('input');
|
||||||
|
maxHeightInput.placeholder = `${pHeight} px`;
|
||||||
|
maxHeightInput.value = height.toString();
|
||||||
|
GoogMoreBox.wrap('div', [maxHeightLabel, maxHeightInput], innerDiv);
|
||||||
|
this.maxHeightInput = maxHeightInput;
|
||||||
|
|
||||||
|
innerDiv.appendChild(btn);
|
||||||
|
const fitButton = document.createElement('button');
|
||||||
|
fitButton.innerText = 'Fit';
|
||||||
|
fitButton.onclick = this.fit;
|
||||||
|
innerDiv.insertBefore(fitButton, innerDiv.firstChild);
|
||||||
|
const resetButton = document.createElement('button');
|
||||||
|
resetButton.innerText = 'Reset';
|
||||||
|
resetButton.onclick = this.reset;
|
||||||
|
innerDiv.insertBefore(resetButton, innerDiv.firstChild);
|
||||||
|
commands.push(spoiler);
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
action === CommandControlMessage.TYPE_SET_CLIPBOARD ||
|
||||||
|
action === CommandControlMessage.TYPE_GET_CLIPBOARD
|
||||||
|
) {
|
||||||
|
inputWrapper.appendChild(btn);
|
||||||
|
} else {
|
||||||
|
commands.push(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
btn.innerText = command;
|
||||||
|
if (action === ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const bitrate = parseInt(bitrateInput.value, 10);
|
||||||
|
const maxFps = parseInt(maxFpsInput.value, 10);
|
||||||
|
const iFrameInterval = parseInt(iFrameIntervalInput.value, 10);
|
||||||
|
if (isNaN(bitrate) || isNaN(maxFps)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const width = parseInt(maxWidthInput.value, 10) & ~15;
|
||||||
|
const height = parseInt(maxHeightInput.value, 10) & ~15;
|
||||||
|
const bounds = new Size(width, height);
|
||||||
|
const current = player.getVideoSettings();
|
||||||
|
const { lockedVideoOrientation, sendFrameMeta, displayId, codecOptions, encoderName } = current;
|
||||||
|
const videoSettings = new VideoSettings({
|
||||||
|
bounds,
|
||||||
|
bitrate,
|
||||||
|
maxFps,
|
||||||
|
iFrameInterval,
|
||||||
|
lockedVideoOrientation,
|
||||||
|
sendFrameMeta,
|
||||||
|
displayId,
|
||||||
|
codecOptions,
|
||||||
|
encoderName,
|
||||||
|
});
|
||||||
|
client.sendNewVideoSetting(videoSettings);
|
||||||
|
};
|
||||||
|
} else if (action === CommandControlMessage.TYPE_SET_CLIPBOARD) {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const text = input.value;
|
||||||
|
if (text) {
|
||||||
|
console.log(`粘贴的内容${text}`);
|
||||||
|
client.sendMessage(CommandControlMessage.createSetClipboardCommand(text));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
btn.onclick = () => {
|
||||||
|
client.sendMessage(new CommandControlMessage(action));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GoogMoreBox.wrap('p', commands, moreBox);
|
||||||
|
|
||||||
|
const screenPowerModeId = `screen_power_mode_${udid}_${playerName}_${displayId}`;
|
||||||
|
const screenPowerModeLabel = document.createElement('label');
|
||||||
|
screenPowerModeLabel.style.display = 'none';
|
||||||
|
const labelTextPrefix = 'Mode';
|
||||||
|
const buttonTextPrefix = 'Set screen power mode';
|
||||||
|
const screenPowerModeCheck = document.createElement('input');
|
||||||
|
screenPowerModeCheck.type = 'checkbox';
|
||||||
|
let mode = (screenPowerModeCheck.checked = false) ? 'ON' : 'OFF';
|
||||||
|
screenPowerModeCheck.id = screenPowerModeLabel.htmlFor = screenPowerModeId;
|
||||||
|
screenPowerModeLabel.innerText = `${labelTextPrefix} ${mode}`;
|
||||||
|
screenPowerModeCheck.onchange = () => {
|
||||||
|
mode = screenPowerModeCheck.checked ? 'ON' : 'OFF';
|
||||||
|
screenPowerModeLabel.innerText = `${labelTextPrefix} ${mode}`;
|
||||||
|
sendScreenPowerModeButton.innerText = `${buttonTextPrefix} ${mode}`;
|
||||||
|
};
|
||||||
|
const sendScreenPowerModeButton = document.createElement('button');
|
||||||
|
sendScreenPowerModeButton.innerText = `${buttonTextPrefix} ${mode}`;
|
||||||
|
sendScreenPowerModeButton.onclick = () => {
|
||||||
|
const message = CommandControlMessage.createSetScreenPowerModeCommand(screenPowerModeCheck.checked);
|
||||||
|
client.sendMessage(message);
|
||||||
|
};
|
||||||
|
GoogMoreBox.wrap('p', [screenPowerModeCheck, screenPowerModeLabel, sendScreenPowerModeButton], moreBox, [
|
||||||
|
'flex-center',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const qualityId = `show_video_quality_${udid}_${playerName}_${displayId}`;
|
||||||
|
const qualityLabel = document.createElement('label');
|
||||||
|
const qualityCheck = document.createElement('input');
|
||||||
|
qualityCheck.type = 'checkbox';
|
||||||
|
qualityCheck.checked = BasePlayer.DEFAULT_SHOW_QUALITY_STATS;
|
||||||
|
qualityCheck.id = qualityId;
|
||||||
|
qualityLabel.htmlFor = qualityId;
|
||||||
|
qualityLabel.innerText = 'Show quality stats';
|
||||||
|
GoogMoreBox.wrap('p', [qualityCheck, qualityLabel], moreBox, ['flex-center']);
|
||||||
|
qualityCheck.onchange = () => {
|
||||||
|
player.setShowQualityStats(qualityCheck.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = (ev?: string | Event) => {
|
||||||
|
if (ev && ev instanceof Event && ev.type === 'error') {
|
||||||
|
console.error(TAG, ev);
|
||||||
|
}
|
||||||
|
const parent = moreBox.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.removeChild(moreBox);
|
||||||
|
}
|
||||||
|
player.off('video-view-resize', this.onViewVideoResize);
|
||||||
|
if (this.onStop) {
|
||||||
|
this.onStop();
|
||||||
|
delete this.onStop;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopBtn = document.createElement('button') as HTMLButtonElement;
|
||||||
|
stopBtn.innerText = `Disconnect`;
|
||||||
|
stopBtn.onclick = stop;
|
||||||
|
|
||||||
|
GoogMoreBox.wrap('p', [stopBtn], moreBox);
|
||||||
|
player.on('video-view-resize', this.onViewVideoResize);
|
||||||
|
player.on('video-settings', this.onVideoSettings);
|
||||||
|
this.holder = moreBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onViewVideoResize = (size: Size): void => {
|
||||||
|
// padding: 10px
|
||||||
|
this.holder.style.width = `${size.width - 2 * 10}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
private onVideoSettings = (videoSettings: VideoSettings): void => {
|
||||||
|
if (this.bitrateInput) {
|
||||||
|
this.bitrateInput.value = videoSettings.bitrate.toString();
|
||||||
|
}
|
||||||
|
if (this.maxFpsInput) {
|
||||||
|
this.maxFpsInput.value = videoSettings.maxFps.toString();
|
||||||
|
}
|
||||||
|
if (this.iFrameIntervalInput) {
|
||||||
|
this.iFrameIntervalInput.value = videoSettings.iFrameInterval.toString();
|
||||||
|
}
|
||||||
|
if (videoSettings.bounds) {
|
||||||
|
const { width, height } = videoSettings.bounds;
|
||||||
|
if (this.maxWidthInput) {
|
||||||
|
this.maxWidthInput.value = width.toString();
|
||||||
|
}
|
||||||
|
if (this.maxHeightInput) {
|
||||||
|
this.maxHeightInput.value = height.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private fit = (): void => {
|
||||||
|
const { width, height } = this.client.getMaxSize() || GoogMoreBox.defaultSize;
|
||||||
|
if (this.maxWidthInput) {
|
||||||
|
this.maxWidthInput.value = width.toString();
|
||||||
|
}
|
||||||
|
if (this.maxHeightInput) {
|
||||||
|
this.maxHeightInput.value = height.toString();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private reset = (): void => {
|
||||||
|
const preferredSettings = this.player.getPreferredVideoSetting();
|
||||||
|
this.onVideoSettings(preferredSettings);
|
||||||
|
};
|
||||||
|
|
||||||
|
public OnDeviceMessage(ev: DeviceMessage): void {
|
||||||
|
if (ev.type !== DeviceMessage.TYPE_CLIPBOARD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.input.value = ev.getText();
|
||||||
|
console.log("this.input.value", this.input.value)
|
||||||
|
this.input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static wrap(
|
||||||
|
tagName: string,
|
||||||
|
elements: HTMLElement[],
|
||||||
|
parent: HTMLElement,
|
||||||
|
opt_classes?: string[],
|
||||||
|
): HTMLElement {
|
||||||
|
const wrap = document.createElement(tagName);
|
||||||
|
if (opt_classes) {
|
||||||
|
wrap.classList.add(...opt_classes);
|
||||||
|
}
|
||||||
|
elements.forEach((e) => {
|
||||||
|
wrap.appendChild(e);
|
||||||
|
});
|
||||||
|
parent.appendChild(wrap);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHolderElement(): HTMLElement {
|
||||||
|
return this.holder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setOnStop(listener: () => void): void {
|
||||||
|
this.onStop = listener;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/app/googDevice/toolbox/GoogToolBox.ts
Normal file
122
src/app/googDevice/toolbox/GoogToolBox.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { ToolBox } from '../../toolbox/ToolBox';
|
||||||
|
import KeyEvent from '../android/KeyEvent';
|
||||||
|
import SvgImage from '../../ui/SvgImage';
|
||||||
|
import { KeyCodeControlMessage } from '../../controlMessage/KeyCodeControlMessage';
|
||||||
|
import { ToolBoxButton } from '../../toolbox/ToolBoxButton';
|
||||||
|
import { ToolBoxElement } from '../../toolbox/ToolBoxElement';
|
||||||
|
import { ToolBoxCheckbox } from '../../toolbox/ToolBoxCheckbox';
|
||||||
|
import { StreamClientScrcpy } from '../client/StreamClientScrcpy';
|
||||||
|
import { BasePlayer } from '../../player/BasePlayer';
|
||||||
|
|
||||||
|
const BUTTONS = [
|
||||||
|
{
|
||||||
|
title: 'Power',
|
||||||
|
code: KeyEvent.KEYCODE_POWER,
|
||||||
|
icon: SvgImage.Icon.POWER,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Volume up',
|
||||||
|
code: KeyEvent.KEYCODE_VOLUME_UP,
|
||||||
|
icon: SvgImage.Icon.VOLUME_UP,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Volume down',
|
||||||
|
code: KeyEvent.KEYCODE_VOLUME_DOWN,
|
||||||
|
icon: SvgImage.Icon.VOLUME_DOWN,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Back',
|
||||||
|
code: KeyEvent.KEYCODE_BACK,
|
||||||
|
icon: SvgImage.Icon.BACK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
code: KeyEvent.KEYCODE_HOME,
|
||||||
|
icon: SvgImage.Icon.HOME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
code: KeyEvent.KEYCODE_APP_SWITCH,
|
||||||
|
icon: SvgImage.Icon.OVERVIEW,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '打开抖音',
|
||||||
|
code: -1, // 用特殊值标记,后续处理
|
||||||
|
icon: SvgImage.Icon.OVERVIEW,
|
||||||
|
action: 'launch_app', // 自定义字段,标记特殊动作
|
||||||
|
packageName: 'com.ss.android.ugc.aweme', // 抖音包名
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export class GoogToolBox extends ToolBox {
|
||||||
|
protected constructor(list: ToolBoxElement<any>[]) {
|
||||||
|
super(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createToolBox(
|
||||||
|
udid: string,
|
||||||
|
player: BasePlayer,
|
||||||
|
client: StreamClientScrcpy,
|
||||||
|
moreBox?: HTMLElement,
|
||||||
|
): GoogToolBox {
|
||||||
|
const playerName = player.getName();
|
||||||
|
const list = BUTTONS.slice();
|
||||||
|
console.log("list", list)
|
||||||
|
const handler = <K extends keyof HTMLElementEventMap, T extends HTMLElement>(
|
||||||
|
type: K,
|
||||||
|
element: ToolBoxElement<T>,
|
||||||
|
) => {
|
||||||
|
const { code } = element.optional || {};
|
||||||
|
|
||||||
|
// 处理普通按键事件
|
||||||
|
if (code && code !== -1) {
|
||||||
|
const actionType = type === 'mousedown' ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP;
|
||||||
|
const event = new KeyCodeControlMessage(actionType, code, 0, 0);
|
||||||
|
client.sendMessage(event);
|
||||||
|
// 处理启动抖音应用
|
||||||
|
} else if (code == -1) {
|
||||||
|
console.log("启动抖音")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
const elements: ToolBoxElement<any>[] = list.map((item) => {
|
||||||
|
const button = new ToolBoxButton(item.title, item.icon, {
|
||||||
|
code: item.code,
|
||||||
|
});
|
||||||
|
button.addEventListener('mousedown', handler);
|
||||||
|
button.addEventListener('mouseup', handler);
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
if (player.supportsScreenshot) {
|
||||||
|
const screenshot = new ToolBoxButton('Take screenshot', SvgImage.Icon.CAMERA);
|
||||||
|
screenshot.addEventListener('click', () => {
|
||||||
|
player.createScreenshot(client.getDeviceName());
|
||||||
|
});
|
||||||
|
elements.push(screenshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboard = new ToolBoxCheckbox(
|
||||||
|
'Capture keyboard',
|
||||||
|
SvgImage.Icon.KEYBOARD,
|
||||||
|
`capture_keyboard_${udid}_${playerName}`,
|
||||||
|
);
|
||||||
|
keyboard.addEventListener('click', (_, el) => {
|
||||||
|
const element = el.getElement();
|
||||||
|
client.setHandleKeyboardEvents(element.checked);
|
||||||
|
});
|
||||||
|
elements.push(keyboard);
|
||||||
|
|
||||||
|
if (moreBox) {
|
||||||
|
const displayId = player.getVideoSettings().displayId;
|
||||||
|
const id = `show_more_${udid}_${playerName}_${displayId}`;
|
||||||
|
const more = new ToolBoxCheckbox('More', SvgImage.Icon.MORE, id);
|
||||||
|
more.addEventListener('click', (_, el) => {
|
||||||
|
const element = el.getElement();
|
||||||
|
moreBox.style.display = element.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
elements.unshift(more);
|
||||||
|
}
|
||||||
|
return new GoogToolBox(elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/app/index.ts
Normal file
112
src/app/index.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import '../style/app.css';
|
||||||
|
import { StreamClientScrcpy } from './googDevice/client/StreamClientScrcpy';
|
||||||
|
import { HostTracker } from './client/HostTracker';
|
||||||
|
import { Tool } from './client/Tool';
|
||||||
|
|
||||||
|
window.onload = async function (): Promise<void> {
|
||||||
|
const hash = location.hash.replace(/^#!/, '');
|
||||||
|
const parsedQuery = new URLSearchParams(hash);
|
||||||
|
const action = parsedQuery.get('action');
|
||||||
|
|
||||||
|
/// #if USE_BROADWAY
|
||||||
|
const { BroadwayPlayer } = await import('./player/BroadwayPlayer');
|
||||||
|
StreamClientScrcpy.registerPlayer(BroadwayPlayer);
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
/// #if USE_H264_CONVERTER
|
||||||
|
const { MsePlayer } = await import('./player/MsePlayer');
|
||||||
|
StreamClientScrcpy.registerPlayer(MsePlayer);
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
/// #if USE_TINY_H264
|
||||||
|
const { TinyH264Player } = await import('./player/TinyH264Player');
|
||||||
|
StreamClientScrcpy.registerPlayer(TinyH264Player);
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
/// #if USE_WEBCODECS
|
||||||
|
const { WebCodecsPlayer } = await import('./player/WebCodecsPlayer');
|
||||||
|
StreamClientScrcpy.registerPlayer(WebCodecsPlayer);
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
if (action === StreamClientScrcpy.ACTION && typeof parsedQuery.get('udid') === 'string') {
|
||||||
|
StreamClientScrcpy.start(parsedQuery);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #if INCLUDE_APPL
|
||||||
|
{
|
||||||
|
const { DeviceTracker } = await import('./applDevice/client/DeviceTracker');
|
||||||
|
|
||||||
|
/// #if USE_QVH_SERVER
|
||||||
|
const { StreamClientQVHack } = await import('./applDevice/client/StreamClientQVHack');
|
||||||
|
|
||||||
|
DeviceTracker.registerTool(StreamClientQVHack);
|
||||||
|
|
||||||
|
/// #if USE_WEBCODECS
|
||||||
|
const { WebCodecsPlayer } = await import('./player/WebCodecsPlayer');
|
||||||
|
StreamClientQVHack.registerPlayer(WebCodecsPlayer);
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
/// #if USE_H264_CONVERTER
|
||||||
|
const { MsePlayerForQVHack } = await import('./player/MsePlayerForQVHack');
|
||||||
|
StreamClientQVHack.registerPlayer(MsePlayerForQVHack);
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
if (action === StreamClientQVHack.ACTION && typeof parsedQuery.get('udid') === 'string') {
|
||||||
|
StreamClientQVHack.start(StreamClientQVHack.parseParameters(parsedQuery));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
/// #if USE_WDA_MJPEG_SERVER
|
||||||
|
const { StreamClientMJPEG } = await import('./applDevice/client/StreamClientMJPEG');
|
||||||
|
DeviceTracker.registerTool(StreamClientMJPEG);
|
||||||
|
|
||||||
|
const { MjpegPlayer } = await import('./player/MjpegPlayer');
|
||||||
|
StreamClientMJPEG.registerPlayer(MjpegPlayer);
|
||||||
|
|
||||||
|
if (action === StreamClientMJPEG.ACTION && typeof parsedQuery.get('udid') === 'string') {
|
||||||
|
StreamClientMJPEG.start(StreamClientMJPEG.parseParameters(parsedQuery));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/// #endif
|
||||||
|
}
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
const tools: Tool[] = [];
|
||||||
|
|
||||||
|
/// #if INCLUDE_ADB_SHELL
|
||||||
|
const { ShellClient } = await import('./googDevice/client/ShellClient');
|
||||||
|
if (action === ShellClient.ACTION && typeof parsedQuery.get('udid') === 'string') {
|
||||||
|
ShellClient.start(ShellClient.parseParameters(parsedQuery));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tools.push(ShellClient);
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
/// #if INCLUDE_DEV_TOOLS
|
||||||
|
const { DevtoolsClient } = await import('./googDevice/client/DevtoolsClient');
|
||||||
|
if (action === DevtoolsClient.ACTION) {
|
||||||
|
DevtoolsClient.start(DevtoolsClient.parseParameters(parsedQuery));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tools.push(DevtoolsClient);
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
/// #if INCLUDE_FILE_LISTING
|
||||||
|
const { FileListingClient } = await import('./googDevice/client/FileListingClient');
|
||||||
|
if (action === FileListingClient.ACTION) {
|
||||||
|
FileListingClient.start(FileListingClient.parseParameters(parsedQuery));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tools.push(FileListingClient);
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
if (tools.length) {
|
||||||
|
const { DeviceTracker } = await import('./googDevice/client/DeviceTracker');
|
||||||
|
tools.forEach((tool) => {
|
||||||
|
DeviceTracker.registerTool(tool);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
HostTracker.start();
|
||||||
|
};
|
||||||
132
src/app/interactionHandler/FeaturedInteractionHandler.ts
Normal file
132
src/app/interactionHandler/FeaturedInteractionHandler.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { InteractionEvents, KeyEventNames, InteractionHandler } from './InteractionHandler';
|
||||||
|
import { BasePlayer } from '../player/BasePlayer';
|
||||||
|
import { ControlMessage } from '../controlMessage/ControlMessage';
|
||||||
|
import { TouchControlMessage } from '../controlMessage/TouchControlMessage';
|
||||||
|
import MotionEvent from '../MotionEvent';
|
||||||
|
import ScreenInfo from '../ScreenInfo';
|
||||||
|
import { ScrollControlMessage } from '../controlMessage/ScrollControlMessage';
|
||||||
|
|
||||||
|
const TAG = '[FeaturedTouchHandler]';
|
||||||
|
|
||||||
|
export interface InteractionHandlerListener {
|
||||||
|
sendMessage: (message: ControlMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FeaturedInteractionHandler extends InteractionHandler {
|
||||||
|
private static readonly touchEventsNames: InteractionEvents[] = [
|
||||||
|
'touchstart',
|
||||||
|
'touchend',
|
||||||
|
'touchmove',
|
||||||
|
'touchcancel',
|
||||||
|
'mousedown',
|
||||||
|
'mouseup',
|
||||||
|
'mousemove',
|
||||||
|
'wheel',
|
||||||
|
];
|
||||||
|
private static readonly keyEventsNames: KeyEventNames[] = ['keydown', 'keyup'];
|
||||||
|
public static SCROLL_EVENT_THROTTLING_TIME = 30; // one event per 50ms
|
||||||
|
private readonly storedFromMouseEvent = new Map<number, TouchControlMessage>();
|
||||||
|
private readonly storedFromTouchEvent = new Map<number, TouchControlMessage>();
|
||||||
|
private lastScrollEvent?: { time: number; hScroll: number; vScroll: number };
|
||||||
|
|
||||||
|
constructor(player: BasePlayer, public readonly listener: InteractionHandlerListener) {
|
||||||
|
super(player, FeaturedInteractionHandler.touchEventsNames, FeaturedInteractionHandler.keyEventsNames);
|
||||||
|
this.tag.addEventListener('mouseleave', this.onMouseLeave);
|
||||||
|
this.tag.addEventListener('mouseenter', this.onMouseEnter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildScrollEvent(event: WheelEvent, screenInfo: ScreenInfo): ScrollControlMessage[] {
|
||||||
|
const messages: ScrollControlMessage[] = [];
|
||||||
|
const touchOnClient = InteractionHandler.buildTouchOnClient(event, screenInfo);
|
||||||
|
if (touchOnClient) {
|
||||||
|
const hScroll = event.deltaX > 0 ? -1 : event.deltaX < -0 ? 1 : 0;
|
||||||
|
const vScroll = event.deltaY > 0 ? -1 : event.deltaY < -0 ? 1 : 0;
|
||||||
|
const time = Date.now();
|
||||||
|
if (
|
||||||
|
!this.lastScrollEvent ||
|
||||||
|
time - this.lastScrollEvent.time > FeaturedInteractionHandler.SCROLL_EVENT_THROTTLING_TIME ||
|
||||||
|
this.lastScrollEvent.vScroll !== vScroll ||
|
||||||
|
this.lastScrollEvent.hScroll !== hScroll
|
||||||
|
) {
|
||||||
|
this.lastScrollEvent = { time, hScroll, vScroll };
|
||||||
|
messages.push(new ScrollControlMessage(touchOnClient.touch.position, hScroll, vScroll));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onInteraction(event: MouseEvent | TouchEvent): void {
|
||||||
|
const screenInfo = this.player.getScreenInfo();
|
||||||
|
if (!screenInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let messages: ControlMessage[];
|
||||||
|
let storage: Map<number, TouchControlMessage>;
|
||||||
|
if (event instanceof MouseEvent) {
|
||||||
|
if (event.target !== this.tag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window['WheelEvent'] && event instanceof WheelEvent) {
|
||||||
|
messages = this.buildScrollEvent(event, screenInfo);
|
||||||
|
} else {
|
||||||
|
storage = this.storedFromMouseEvent;
|
||||||
|
messages = this.buildTouchEvent(event, screenInfo, storage);
|
||||||
|
}
|
||||||
|
if (this.over) {
|
||||||
|
this.lastPosition = event;
|
||||||
|
}
|
||||||
|
} else if (window['TouchEvent'] && event instanceof TouchEvent) {
|
||||||
|
// TODO: Research drag from out of the target inside it
|
||||||
|
if (event.target !== this.tag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storage = this.storedFromTouchEvent;
|
||||||
|
messages = this.formatTouchEvent(event, screenInfo, storage);
|
||||||
|
} else {
|
||||||
|
console.error(TAG, 'Unsupported event', event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.cancelable) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
messages.forEach((message) => {
|
||||||
|
this.listener.sendMessage(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKey(event: KeyboardEvent): void {
|
||||||
|
if (!this.lastPosition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const screenInfo = this.player.getScreenInfo();
|
||||||
|
if (!screenInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { ctrlKey, shiftKey } = event;
|
||||||
|
const { target, button, buttons, clientY, clientX } = this.lastPosition;
|
||||||
|
const type = InteractionHandler.SIMULATE_MULTI_TOUCH;
|
||||||
|
const props = { ctrlKey, shiftKey, type, target, button, buttons, clientX, clientY };
|
||||||
|
this.buildTouchEvent(props, screenInfo, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseEnter = (): void => {
|
||||||
|
this.over = true;
|
||||||
|
};
|
||||||
|
private onMouseLeave = (): void => {
|
||||||
|
this.lastPosition = undefined;
|
||||||
|
this.over = false;
|
||||||
|
this.storedFromMouseEvent.forEach((message) => {
|
||||||
|
this.listener.sendMessage(InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_UP, message));
|
||||||
|
});
|
||||||
|
this.storedFromMouseEvent.clear();
|
||||||
|
this.clearCanvas();
|
||||||
|
};
|
||||||
|
|
||||||
|
public release(): void {
|
||||||
|
super.release();
|
||||||
|
this.tag.removeEventListener('mouseleave', this.onMouseLeave);
|
||||||
|
this.tag.removeEventListener('mouseenter', this.onMouseEnter);
|
||||||
|
this.storedFromMouseEvent.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
562
src/app/interactionHandler/InteractionHandler.ts
Normal file
562
src/app/interactionHandler/InteractionHandler.ts
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
import MotionEvent from '../MotionEvent';
|
||||||
|
import ScreenInfo from '../ScreenInfo';
|
||||||
|
import { TouchControlMessage } from '../controlMessage/TouchControlMessage';
|
||||||
|
import Size from '../Size';
|
||||||
|
import Point from '../Point';
|
||||||
|
import Position from '../Position';
|
||||||
|
import TouchPointPNG from '../../public/images/multitouch/touch_point.png';
|
||||||
|
import CenterPointPNG from '../../public/images/multitouch/center_point.png';
|
||||||
|
import Util from '../Util';
|
||||||
|
import { BasePlayer } from '../player/BasePlayer';
|
||||||
|
|
||||||
|
interface Touch {
|
||||||
|
action: number;
|
||||||
|
position: Position;
|
||||||
|
buttons: number;
|
||||||
|
invalid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TouchOnClient {
|
||||||
|
client: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
touch: Touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommonTouchAndMouse {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
type: string;
|
||||||
|
target: EventTarget | null;
|
||||||
|
buttons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MiniMouseEvent extends CommonTouchAndMouse {
|
||||||
|
ctrlKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
buttons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG = '[TouchHandler]';
|
||||||
|
|
||||||
|
export type TouchEventNames =
|
||||||
|
| 'touchstart'
|
||||||
|
| 'touchend'
|
||||||
|
| 'touchmove'
|
||||||
|
| 'touchcancel'
|
||||||
|
| 'mousedown'
|
||||||
|
| 'mouseup'
|
||||||
|
| 'mousemove';
|
||||||
|
export type WheelEventNames = 'wheel';
|
||||||
|
export type InteractionEvents = TouchEventNames | WheelEventNames;
|
||||||
|
export type KeyEventNames = 'keydown' | 'keyup';
|
||||||
|
|
||||||
|
export abstract class InteractionHandler {
|
||||||
|
protected static readonly SIMULATE_MULTI_TOUCH = 'SIMULATE_MULTI_TOUCH';
|
||||||
|
protected static readonly STROKE_STYLE: string = '#00BEA4';
|
||||||
|
protected static EVENT_ACTION_MAP: Record<string, number> = {
|
||||||
|
touchstart: MotionEvent.ACTION_DOWN,
|
||||||
|
touchend: MotionEvent.ACTION_UP,
|
||||||
|
touchmove: MotionEvent.ACTION_MOVE,
|
||||||
|
touchcancel: MotionEvent.ACTION_UP,
|
||||||
|
mousedown: MotionEvent.ACTION_DOWN,
|
||||||
|
mousemove: MotionEvent.ACTION_MOVE,
|
||||||
|
mouseup: MotionEvent.ACTION_UP,
|
||||||
|
[InteractionHandler.SIMULATE_MULTI_TOUCH]: -1,
|
||||||
|
};
|
||||||
|
private static options = Util.supportsPassive() ? { passive: false } : false;
|
||||||
|
private static idToPointerMap: Map<number, number> = new Map();
|
||||||
|
private static pointerToIdMap: Map<number, number> = new Map();
|
||||||
|
private static touchPointRadius = 10;
|
||||||
|
private static centerPointRadius = 5;
|
||||||
|
private static touchPointImage?: HTMLImageElement;
|
||||||
|
private static centerPointImage?: HTMLImageElement;
|
||||||
|
private static pointImagesLoaded = false;
|
||||||
|
private static eventListeners: Map<string, Set<InteractionHandler>> = new Map();
|
||||||
|
private multiTouchActive = false;
|
||||||
|
private multiTouchCenter?: Point;
|
||||||
|
private multiTouchShift = false;
|
||||||
|
private dirtyPlace: Point[] = [];
|
||||||
|
protected readonly ctx: CanvasRenderingContext2D | null;
|
||||||
|
protected readonly tag: HTMLCanvasElement;
|
||||||
|
protected over = false;
|
||||||
|
protected lastPosition?: MouseEvent;
|
||||||
|
|
||||||
|
protected constructor(
|
||||||
|
public readonly player: BasePlayer,
|
||||||
|
public readonly touchEventsNames: InteractionEvents[],
|
||||||
|
public readonly keyEventsNames: KeyEventNames[],
|
||||||
|
) {
|
||||||
|
this.tag = player.getTouchableElement();
|
||||||
|
this.ctx = this.tag.getContext('2d');
|
||||||
|
InteractionHandler.loadImages();
|
||||||
|
InteractionHandler.bindGlobalListeners(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract onInteraction(event: MouseEvent | TouchEvent): void;
|
||||||
|
protected abstract onKey(event: KeyboardEvent): void;
|
||||||
|
|
||||||
|
protected static bindGlobalListeners(interactionHandler: InteractionHandler): void {
|
||||||
|
interactionHandler.touchEventsNames.forEach((eventName) => {
|
||||||
|
let set: Set<InteractionHandler> | undefined = InteractionHandler.eventListeners.get(eventName);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
document.body.addEventListener(eventName, this.onInteractionEvent, InteractionHandler.options);
|
||||||
|
this.eventListeners.set(eventName, set);
|
||||||
|
}
|
||||||
|
set.add(interactionHandler);
|
||||||
|
});
|
||||||
|
interactionHandler.keyEventsNames.forEach((eventName) => {
|
||||||
|
let set = InteractionHandler.eventListeners.get(eventName);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
document.body.addEventListener(eventName, this.onKeyEvent);
|
||||||
|
this.eventListeners.set(eventName, set);
|
||||||
|
}
|
||||||
|
set.add(interactionHandler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static unbindListeners(touchHandler: InteractionHandler): void {
|
||||||
|
touchHandler.touchEventsNames.forEach((eventName) => {
|
||||||
|
const set = InteractionHandler.eventListeners.get(eventName);
|
||||||
|
if (!set) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set.delete(touchHandler);
|
||||||
|
if (set.size <= 0) {
|
||||||
|
this.eventListeners.delete(eventName);
|
||||||
|
document.body.removeEventListener(eventName, this.onInteractionEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
touchHandler.keyEventsNames.forEach((eventName) => {
|
||||||
|
const set = InteractionHandler.eventListeners.get(eventName);
|
||||||
|
if (!set) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set.delete(touchHandler);
|
||||||
|
if (set.size <= 0) {
|
||||||
|
this.eventListeners.delete(eventName);
|
||||||
|
document.body.removeEventListener(eventName, this.onKeyEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static onInteractionEvent = (event: MouseEvent | TouchEvent): void => {
|
||||||
|
const set = InteractionHandler.eventListeners.get(event.type as TouchEventNames);
|
||||||
|
if (!set) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set.forEach((instance) => {
|
||||||
|
instance.onInteraction(event);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
protected static onKeyEvent = (event: KeyboardEvent): void => {
|
||||||
|
const set = InteractionHandler.eventListeners.get(event.type as KeyEventNames);
|
||||||
|
if (!set) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set.forEach((instance) => {
|
||||||
|
instance.onKey(event);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
protected static loadImages(): void {
|
||||||
|
if (this.pointImagesLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total = 2;
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
const onload = (event: Event) => {
|
||||||
|
if (++current === total) {
|
||||||
|
this.pointImagesLoaded = true;
|
||||||
|
}
|
||||||
|
if (event.target === this.touchPointImage) {
|
||||||
|
this.touchPointRadius = this.touchPointImage.width / 2;
|
||||||
|
} else if (event.target === this.centerPointImage) {
|
||||||
|
this.centerPointRadius = this.centerPointImage.width / 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const touch = (this.touchPointImage = new Image());
|
||||||
|
touch.src = TouchPointPNG;
|
||||||
|
touch.onload = onload;
|
||||||
|
const center = (this.centerPointImage = new Image());
|
||||||
|
center.src = CenterPointPNG;
|
||||||
|
center.onload = onload;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static getPointerId(type: string, identifier: number): number {
|
||||||
|
if (this.idToPointerMap.has(identifier)) {
|
||||||
|
const pointerId = this.idToPointerMap.get(identifier) as number;
|
||||||
|
if (type === 'touchend' || type === 'touchcancel') {
|
||||||
|
this.idToPointerMap.delete(identifier);
|
||||||
|
this.pointerToIdMap.delete(pointerId);
|
||||||
|
}
|
||||||
|
return pointerId;
|
||||||
|
}
|
||||||
|
let pointerId = 0;
|
||||||
|
while (this.pointerToIdMap.has(pointerId)) {
|
||||||
|
pointerId++;
|
||||||
|
}
|
||||||
|
this.idToPointerMap.set(identifier, pointerId);
|
||||||
|
this.pointerToIdMap.set(pointerId, identifier);
|
||||||
|
return pointerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static buildTouchOnClient(event: CommonTouchAndMouse, screenInfo: ScreenInfo): TouchOnClient | null {
|
||||||
|
const action = this.mapTypeToAction(event.type);
|
||||||
|
const { width, height } = screenInfo.videoSize;
|
||||||
|
const target: HTMLElement = event.target as HTMLElement;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
let { clientWidth, clientHeight } = target;
|
||||||
|
let touchX = event.clientX - rect.left;
|
||||||
|
let touchY = event.clientY - rect.top;
|
||||||
|
let invalid = false;
|
||||||
|
if (touchX < 0 || touchX > clientWidth || touchY < 0 || touchY > clientHeight) {
|
||||||
|
invalid = true;
|
||||||
|
}
|
||||||
|
const eps = 1e5;
|
||||||
|
const ratio = width / height;
|
||||||
|
const shouldBe = Math.round(eps * ratio);
|
||||||
|
const haveNow = Math.round((eps * clientWidth) / clientHeight);
|
||||||
|
if (shouldBe > haveNow) {
|
||||||
|
const realHeight = Math.ceil(clientWidth / ratio);
|
||||||
|
const top = (clientHeight - realHeight) / 2;
|
||||||
|
if (touchY < top || touchY > top + realHeight) {
|
||||||
|
invalid = true;
|
||||||
|
}
|
||||||
|
touchY -= top;
|
||||||
|
clientHeight = realHeight;
|
||||||
|
} else if (shouldBe < haveNow) {
|
||||||
|
const realWidth = Math.ceil(clientHeight * ratio);
|
||||||
|
const left = (clientWidth - realWidth) / 2;
|
||||||
|
if (touchX < left || touchX > left + realWidth) {
|
||||||
|
invalid = true;
|
||||||
|
}
|
||||||
|
touchX -= left;
|
||||||
|
clientWidth = realWidth;
|
||||||
|
}
|
||||||
|
const x = (touchX * width) / clientWidth;
|
||||||
|
const y = (touchY * height) / clientHeight;
|
||||||
|
const size = new Size(width, height);
|
||||||
|
const point = new Point(x, y);
|
||||||
|
const position = new Position(point, size);
|
||||||
|
if (x < 0 || y < 0 || x > width || y > height) {
|
||||||
|
invalid = true;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
width: clientWidth,
|
||||||
|
height: clientHeight,
|
||||||
|
},
|
||||||
|
touch: {
|
||||||
|
invalid,
|
||||||
|
action,
|
||||||
|
position,
|
||||||
|
buttons: event.buttons,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static validateMessage(
|
||||||
|
originalEvent: MiniMouseEvent | TouchEvent,
|
||||||
|
message: TouchControlMessage,
|
||||||
|
storage: Map<number, TouchControlMessage>,
|
||||||
|
logPrefix: string,
|
||||||
|
): TouchControlMessage[] {
|
||||||
|
const messages: TouchControlMessage[] = [];
|
||||||
|
const { action, pointerId } = message;
|
||||||
|
const previous = storage.get(pointerId);
|
||||||
|
if (action === MotionEvent.ACTION_UP) {
|
||||||
|
if (!previous) {
|
||||||
|
console.warn(logPrefix, 'Received ACTION_UP while there are no DOWN stored');
|
||||||
|
} else {
|
||||||
|
storage.delete(pointerId);
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
} else if (action === MotionEvent.ACTION_DOWN) {
|
||||||
|
if (previous) {
|
||||||
|
console.warn(logPrefix, 'Received ACTION_DOWN while already has one stored');
|
||||||
|
} else {
|
||||||
|
storage.set(pointerId, message);
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
} else if (action === MotionEvent.ACTION_MOVE) {
|
||||||
|
if (!previous) {
|
||||||
|
if (
|
||||||
|
(originalEvent instanceof MouseEvent && originalEvent.buttons) ||
|
||||||
|
(window['TouchEvent'] && originalEvent instanceof TouchEvent)
|
||||||
|
) {
|
||||||
|
console.warn(logPrefix, 'Received ACTION_MOVE while there are no DOWN stored');
|
||||||
|
const emulated = InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_DOWN, message);
|
||||||
|
messages.push(emulated);
|
||||||
|
storage.set(pointerId, emulated);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messages.push(message);
|
||||||
|
storage.set(pointerId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static createEmulatedMessage(action: number, event: TouchControlMessage): TouchControlMessage {
|
||||||
|
const { pointerId, position, buttons } = event;
|
||||||
|
let pressure = event.pressure;
|
||||||
|
if (action === MotionEvent.ACTION_UP) {
|
||||||
|
pressure = 0;
|
||||||
|
}
|
||||||
|
return new TouchControlMessage(action, pointerId, position, pressure, buttons);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static mapTypeToAction(type: string): number {
|
||||||
|
return this.EVENT_ACTION_MAP[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getTouch(
|
||||||
|
e: CommonTouchAndMouse,
|
||||||
|
screenInfo: ScreenInfo,
|
||||||
|
ctrlKey: boolean,
|
||||||
|
shiftKey: boolean,
|
||||||
|
): Touch[] | null {
|
||||||
|
const touchOnClient = InteractionHandler.buildTouchOnClient(e, screenInfo);
|
||||||
|
if (!touchOnClient) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { client, touch } = touchOnClient;
|
||||||
|
const result: Touch[] = [touch];
|
||||||
|
if (!ctrlKey) {
|
||||||
|
this.multiTouchActive = false;
|
||||||
|
this.multiTouchCenter = undefined;
|
||||||
|
this.multiTouchShift = false;
|
||||||
|
this.clearCanvas();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const { position, action, buttons } = touch;
|
||||||
|
const { point, screenSize } = position;
|
||||||
|
const { width, height } = screenSize;
|
||||||
|
const { x, y } = point;
|
||||||
|
if (!this.multiTouchActive) {
|
||||||
|
if (shiftKey) {
|
||||||
|
this.multiTouchCenter = point;
|
||||||
|
this.multiTouchShift = true;
|
||||||
|
} else {
|
||||||
|
this.multiTouchCenter = new Point(client.width / 2, client.height / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.multiTouchActive = true;
|
||||||
|
let opposite: Point | undefined;
|
||||||
|
let invalid = false;
|
||||||
|
if (this.multiTouchShift && this.multiTouchCenter) {
|
||||||
|
const oppoX = 2 * this.multiTouchCenter.x - x;
|
||||||
|
const oppoY = 2 * this.multiTouchCenter.y - y;
|
||||||
|
opposite = new Point(oppoX, oppoY);
|
||||||
|
if (!(oppoX <= width && oppoX >= 0 && oppoY <= height && oppoY >= 0)) {
|
||||||
|
invalid = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
opposite = new Point(client.width - x, client.height - y);
|
||||||
|
invalid = touch.invalid;
|
||||||
|
}
|
||||||
|
if (opposite) {
|
||||||
|
result.push({
|
||||||
|
invalid,
|
||||||
|
action,
|
||||||
|
buttons,
|
||||||
|
position: new Position(opposite, screenSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected drawCircle(ctx: CanvasRenderingContext2D, point: Point, radius: number): void {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(point.x, point.y, radius, 0, Math.PI * 2, true);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public drawLine(point1: Point, point2: Point): void {
|
||||||
|
if (!this.ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.strokeStyle = InteractionHandler.STROKE_STYLE;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(point1.x, point1.y);
|
||||||
|
this.ctx.lineTo(point2.x, point2.y);
|
||||||
|
this.ctx.stroke();
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected drawPoint(point: Point, radius: number, image?: HTMLImageElement): void {
|
||||||
|
if (!this.ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let { lineWidth } = this.ctx;
|
||||||
|
if (InteractionHandler.pointImagesLoaded && image) {
|
||||||
|
radius = image.width / 2;
|
||||||
|
lineWidth = 0;
|
||||||
|
this.ctx.drawImage(image, point.x - radius, point.y - radius);
|
||||||
|
} else {
|
||||||
|
this.drawCircle(this.ctx, point, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLeft = new Point(point.x - radius - lineWidth, point.y - radius - lineWidth);
|
||||||
|
const bottomRight = new Point(point.x + radius + lineWidth, point.y + radius + lineWidth);
|
||||||
|
this.updateDirty(topLeft, bottomRight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public drawPointer(point: Point): void {
|
||||||
|
this.drawPoint(point, InteractionHandler.touchPointRadius, InteractionHandler.touchPointImage);
|
||||||
|
if (this.multiTouchCenter) {
|
||||||
|
this.drawLine(this.multiTouchCenter, point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public drawCenter(point: Point): void {
|
||||||
|
this.drawPoint(point, InteractionHandler.centerPointRadius, InteractionHandler.centerPointImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateDirty(topLeft: Point, bottomRight: Point): void {
|
||||||
|
if (!this.dirtyPlace.length) {
|
||||||
|
this.dirtyPlace.push(topLeft, bottomRight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentTopLeft = this.dirtyPlace[0];
|
||||||
|
const currentBottomRight = this.dirtyPlace[1];
|
||||||
|
const newTopLeft = new Point(Math.min(currentTopLeft.x, topLeft.x), Math.min(currentTopLeft.y, topLeft.y));
|
||||||
|
const newBottomRight = new Point(
|
||||||
|
Math.max(currentBottomRight.x, bottomRight.x),
|
||||||
|
Math.max(currentBottomRight.y, bottomRight.y),
|
||||||
|
);
|
||||||
|
this.dirtyPlace.length = 0;
|
||||||
|
this.dirtyPlace.push(newTopLeft, newBottomRight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCanvas(): void {
|
||||||
|
const { clientWidth, clientHeight } = this.tag;
|
||||||
|
const ctx = this.ctx;
|
||||||
|
if (ctx && this.dirtyPlace.length) {
|
||||||
|
const topLeft = this.dirtyPlace[0];
|
||||||
|
const bottomRight = this.dirtyPlace[1];
|
||||||
|
this.dirtyPlace.length = 0;
|
||||||
|
const x = Math.max(topLeft.x, 0);
|
||||||
|
const y = Math.max(topLeft.y, 0);
|
||||||
|
const w = Math.min(clientWidth, bottomRight.x - x);
|
||||||
|
const h = Math.min(clientHeight, bottomRight.y - y);
|
||||||
|
ctx.clearRect(x, y, w, h);
|
||||||
|
ctx.strokeStyle = InteractionHandler.STROKE_STYLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public formatTouchEvent(
|
||||||
|
e: TouchEvent,
|
||||||
|
screenInfo: ScreenInfo,
|
||||||
|
storage: Map<number, TouchControlMessage>,
|
||||||
|
): TouchControlMessage[] {
|
||||||
|
const logPrefix = `${TAG}[formatTouchEvent]`;
|
||||||
|
const messages: TouchControlMessage[] = [];
|
||||||
|
const touches = e.changedTouches;
|
||||||
|
if (touches && touches.length) {
|
||||||
|
for (let i = 0, l = touches.length; i < l; i++) {
|
||||||
|
const touch = touches[i];
|
||||||
|
const pointerId = InteractionHandler.getPointerId(e.type, touch.identifier);
|
||||||
|
if (touch.target !== this.tag) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const previous = storage.get(pointerId);
|
||||||
|
const item: CommonTouchAndMouse = {
|
||||||
|
clientX: touch.clientX,
|
||||||
|
clientY: touch.clientY,
|
||||||
|
type: e.type,
|
||||||
|
buttons: MotionEvent.BUTTON_PRIMARY,
|
||||||
|
target: e.target,
|
||||||
|
};
|
||||||
|
const event = InteractionHandler.buildTouchOnClient(item, screenInfo);
|
||||||
|
if (event) {
|
||||||
|
const { action, buttons, position, invalid } = event.touch;
|
||||||
|
let pressure = 1;
|
||||||
|
if (action === MotionEvent.ACTION_UP) {
|
||||||
|
pressure = 0;
|
||||||
|
} else if (typeof touch.force === 'number') {
|
||||||
|
pressure = touch.force;
|
||||||
|
}
|
||||||
|
if (!invalid) {
|
||||||
|
const message = new TouchControlMessage(action, pointerId, position, pressure, buttons);
|
||||||
|
messages.push(
|
||||||
|
...InteractionHandler.validateMessage(e, message, storage, `${logPrefix}[validate]`),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (previous) {
|
||||||
|
messages.push(InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_UP, previous));
|
||||||
|
storage.delete(pointerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(logPrefix, `Failed to format touch`, touch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(logPrefix, 'No "touches"', e);
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildTouchEvent(
|
||||||
|
e: MiniMouseEvent,
|
||||||
|
screenInfo: ScreenInfo,
|
||||||
|
storage: Map<number, TouchControlMessage>,
|
||||||
|
): TouchControlMessage[] {
|
||||||
|
const logPrefix = `${TAG}[buildTouchEvent]`;
|
||||||
|
const touches = this.getTouch(e, screenInfo, e.ctrlKey, e.shiftKey);
|
||||||
|
if (!touches) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const messages: TouchControlMessage[] = [];
|
||||||
|
const points: Point[] = [];
|
||||||
|
this.clearCanvas();
|
||||||
|
touches.forEach((touch: Touch, pointerId: number) => {
|
||||||
|
const { action, buttons, position } = touch;
|
||||||
|
const previous = storage.get(pointerId);
|
||||||
|
if (!touch.invalid) {
|
||||||
|
let pressure = 1.0;
|
||||||
|
if (action === MotionEvent.ACTION_UP) {
|
||||||
|
pressure = 0;
|
||||||
|
}
|
||||||
|
const message = new TouchControlMessage(action, pointerId, position, pressure, buttons);
|
||||||
|
messages.push(...InteractionHandler.validateMessage(e, message, storage, `${logPrefix}[validate]`));
|
||||||
|
points.push(touch.position.point);
|
||||||
|
} else {
|
||||||
|
if (previous) {
|
||||||
|
points.push(previous.position.point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.multiTouchActive) {
|
||||||
|
if (this.multiTouchCenter) {
|
||||||
|
this.drawCenter(this.multiTouchCenter);
|
||||||
|
}
|
||||||
|
points.forEach((point) => {
|
||||||
|
this.drawPointer(point);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hasActionUp = messages.find((message) => {
|
||||||
|
return message.action === MotionEvent.ACTION_UP;
|
||||||
|
});
|
||||||
|
if (hasActionUp && storage.size) {
|
||||||
|
console.warn(logPrefix, 'Looks like one of Multi-touch pointers was not raised up');
|
||||||
|
storage.forEach((message) => {
|
||||||
|
messages.push(InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_UP, message));
|
||||||
|
});
|
||||||
|
storage.clear();
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public release(): void {
|
||||||
|
InteractionHandler.unbindListeners(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/app/interactionHandler/SimpleInteractionHandler.ts
Normal file
86
src/app/interactionHandler/SimpleInteractionHandler.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { InteractionEvents, InteractionHandler } from './InteractionHandler';
|
||||||
|
import { BasePlayer } from '../player/BasePlayer';
|
||||||
|
import ScreenInfo from '../ScreenInfo';
|
||||||
|
import Position from '../Position';
|
||||||
|
|
||||||
|
export interface TouchHandlerListener {
|
||||||
|
performClick: (position: Position) => void;
|
||||||
|
performScroll: (from: Position, to: Position) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG = '[SimpleTouchHandler]';
|
||||||
|
|
||||||
|
export class SimpleInteractionHandler extends InteractionHandler {
|
||||||
|
private startPosition?: Position;
|
||||||
|
private endPosition?: Position;
|
||||||
|
private static readonly touchEventsNames: InteractionEvents[] = ['mousedown', 'mouseup', 'mousemove'];
|
||||||
|
private storage = new Map();
|
||||||
|
|
||||||
|
constructor(player: BasePlayer, private readonly listener: TouchHandlerListener) {
|
||||||
|
super(player, SimpleInteractionHandler.touchEventsNames, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onInteraction(event: MouseEvent | TouchEvent): void {
|
||||||
|
let handled = false;
|
||||||
|
if (!(event instanceof MouseEvent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target === this.tag) {
|
||||||
|
const screenInfo: ScreenInfo = this.player.getScreenInfo() as ScreenInfo;
|
||||||
|
if (!screenInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const events = this.buildTouchEvent(event, screenInfo, this.storage);
|
||||||
|
if (events.length > 1) {
|
||||||
|
console.warn(TAG, 'Too many events', events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const downEventName = 'mousedown';
|
||||||
|
if (events.length === 1) {
|
||||||
|
handled = true;
|
||||||
|
if (event.type === downEventName) {
|
||||||
|
this.startPosition = events[0].position;
|
||||||
|
} else {
|
||||||
|
if (this.startPosition) {
|
||||||
|
this.endPosition = events[0].position;
|
||||||
|
} else {
|
||||||
|
console.warn(TAG, `Received "${event.type}" before "${downEventName}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.startPosition) {
|
||||||
|
this.drawPointer(this.startPosition.point);
|
||||||
|
}
|
||||||
|
if (this.endPosition) {
|
||||||
|
this.drawPointer(this.endPosition.point);
|
||||||
|
if (this.startPosition) {
|
||||||
|
this.drawLine(this.startPosition.point, this.endPosition.point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === 'mouseup') {
|
||||||
|
if (this.startPosition && this.endPosition) {
|
||||||
|
this.clearCanvas();
|
||||||
|
if (this.startPosition.point.distance(this.endPosition.point) < 10) {
|
||||||
|
this.listener.performClick(this.endPosition);
|
||||||
|
} else {
|
||||||
|
this.listener.performScroll(this.startPosition, this.endPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (handled) {
|
||||||
|
if (event.cancelable) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === 'mouseup') {
|
||||||
|
this.startPosition = undefined;
|
||||||
|
this.endPosition = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKey(): void {
|
||||||
|
throw Error(`${TAG} Unsupported`);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
src/app/player/BaseCanvasBasedPlayer.ts
Normal file
236
src/app/player/BaseCanvasBasedPlayer.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { BasePlayer, PlaybackQuality } from './BasePlayer';
|
||||||
|
import ScreenInfo from '../ScreenInfo';
|
||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import { DisplayInfo } from '../DisplayInfo';
|
||||||
|
|
||||||
|
type DecodedFrame = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
frame: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CanvasDecoder {
|
||||||
|
decode(buffer: Uint8Array, width: number, height: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BaseCanvasBasedPlayer extends BasePlayer {
|
||||||
|
protected framesList: Uint8Array[] = [];
|
||||||
|
protected decodedFrames: DecodedFrame[] = [];
|
||||||
|
protected videoStats: PlaybackQuality[] = [];
|
||||||
|
protected animationFrameId?: number;
|
||||||
|
protected canvas?: CanvasDecoder;
|
||||||
|
|
||||||
|
public static hasWebGLSupport(): boolean {
|
||||||
|
// For some reason if I use here `this.tag` image on canvas will be flattened
|
||||||
|
const testCanvas: HTMLCanvasElement = document.createElement('canvas');
|
||||||
|
const validContextNames = ['webgl', 'experimental-webgl', 'moz-webgl', 'webkit-3d'];
|
||||||
|
let index = 0;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let gl: any = null;
|
||||||
|
while (!gl && index++ < validContextNames.length) {
|
||||||
|
try {
|
||||||
|
gl = testCanvas.getContext(validContextNames[index]);
|
||||||
|
} catch (error: any) {
|
||||||
|
gl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !!gl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createElement(id?: string): HTMLCanvasElement {
|
||||||
|
const tag = document.createElement('canvas') as HTMLCanvasElement;
|
||||||
|
if (typeof id === 'string') {
|
||||||
|
tag.id = id;
|
||||||
|
}
|
||||||
|
tag.className = 'video-layer';
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
udid: string,
|
||||||
|
displayInfo?: DisplayInfo,
|
||||||
|
name = 'Canvas',
|
||||||
|
storageKeyPrefix = 'DummyCanvas',
|
||||||
|
protected tag: HTMLCanvasElement = BaseCanvasBasedPlayer.createElement(),
|
||||||
|
) {
|
||||||
|
super(udid, displayInfo, name, storageKeyPrefix, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract decode(data: Uint8Array): void;
|
||||||
|
public abstract getPreferredVideoSetting(): VideoSettings;
|
||||||
|
|
||||||
|
protected drawDecoded = (): void => {
|
||||||
|
if (!this.canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.receivedFirstFrame) {
|
||||||
|
const data = this.decodedFrames.shift();
|
||||||
|
if (data) {
|
||||||
|
const { frame, width, height } = data;
|
||||||
|
this.canvas.decode(frame, width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.decodedFrames.length) {
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.drawDecoded);
|
||||||
|
} else {
|
||||||
|
this.animationFrameId = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected onFrameDecoded(width: number, height: number, frame: any): void {
|
||||||
|
if (!this.receivedFirstFrame) {
|
||||||
|
// decoded frame with previous video settings
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dropped = 0;
|
||||||
|
const maxStored = this.videoSettings.maxFps / 10; // for 100ms
|
||||||
|
|
||||||
|
while (this.decodedFrames.length > maxStored) {
|
||||||
|
const data = this.decodedFrames.shift();
|
||||||
|
if (data) {
|
||||||
|
this.dropFrame(data.frame);
|
||||||
|
dropped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.decodedFrames.push({ width, height, frame });
|
||||||
|
this.videoStats.push({
|
||||||
|
decodedFrames: 1,
|
||||||
|
droppedFrames: dropped,
|
||||||
|
inputBytes: 0,
|
||||||
|
inputFrames: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
if (!this.animationFrameId) {
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.drawDecoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected dropFrame(_frame: any): void {
|
||||||
|
// dispose frame if required
|
||||||
|
}
|
||||||
|
|
||||||
|
private shiftFrame(): void {
|
||||||
|
if (this.getState() !== BasePlayer.STATE.PLAYING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = this.framesList.shift();
|
||||||
|
if (first) {
|
||||||
|
this.decode(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected calculateMomentumStats(): void {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const oneSecondBefore = timestamp - 1000;
|
||||||
|
|
||||||
|
while (this.videoStats.length && this.videoStats[0].timestamp < oneSecondBefore) {
|
||||||
|
this.videoStats.shift();
|
||||||
|
}
|
||||||
|
while (this.inputBytes.length && this.inputBytes[0].timestamp < oneSecondBefore) {
|
||||||
|
this.inputBytes.shift();
|
||||||
|
}
|
||||||
|
let decodedFrames = 0;
|
||||||
|
let droppedFrames = 0;
|
||||||
|
let inputBytes = 0;
|
||||||
|
this.videoStats.forEach((item) => {
|
||||||
|
decodedFrames += item.decodedFrames;
|
||||||
|
droppedFrames += item.droppedFrames;
|
||||||
|
});
|
||||||
|
this.inputBytes.forEach((item) => {
|
||||||
|
inputBytes += item.bytes;
|
||||||
|
});
|
||||||
|
this.momentumQualityStats = {
|
||||||
|
decodedFrames,
|
||||||
|
droppedFrames,
|
||||||
|
inputFrames: this.inputBytes.length,
|
||||||
|
inputBytes,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected resetStats(): void {
|
||||||
|
super.resetStats();
|
||||||
|
this.videoStats = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getImageDataURL(): string {
|
||||||
|
return this.tag.toDataURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected initCanvas(width: number, height: number): void {
|
||||||
|
if (this.canvas) {
|
||||||
|
const parent = this.tag.parentNode;
|
||||||
|
if (parent) {
|
||||||
|
const tag = BaseCanvasBasedPlayer.createElement(this.tag.id);
|
||||||
|
tag.className = this.tag.className;
|
||||||
|
parent.replaceChild(tag, this.tag);
|
||||||
|
parent.appendChild(this.touchableCanvas);
|
||||||
|
this.tag = tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.tag.onerror = (event: Event | string): void => {
|
||||||
|
console.error(`[${this.name}]`, event);
|
||||||
|
};
|
||||||
|
this.tag.oncontextmenu = (event: MouseEvent): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
this.tag.width = Math.round(width);
|
||||||
|
this.tag.height = Math.round(height);
|
||||||
|
}
|
||||||
|
|
||||||
|
public play(): void {
|
||||||
|
super.play();
|
||||||
|
if (this.getState() !== BasePlayer.STATE.PLAYING || !this.screenInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.canvas) {
|
||||||
|
const { width, height } = this.screenInfo.videoSize;
|
||||||
|
this.initCanvas(width, height);
|
||||||
|
this.resetStats();
|
||||||
|
}
|
||||||
|
this.shiftFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
super.stop();
|
||||||
|
this.clearState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setScreenInfo(screenInfo: ScreenInfo): void {
|
||||||
|
super.setScreenInfo(screenInfo);
|
||||||
|
this.clearState();
|
||||||
|
const { width, height } = screenInfo.videoSize;
|
||||||
|
this.initCanvas(width, height);
|
||||||
|
this.framesList = [];
|
||||||
|
if (this.animationFrameId) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public pushFrame(frame: Uint8Array): void {
|
||||||
|
super.pushFrame(frame);
|
||||||
|
if (BasePlayer.isIFrame(frame)) {
|
||||||
|
if (this.videoSettings) {
|
||||||
|
const { maxFps } = this.videoSettings;
|
||||||
|
if (this.framesList.length > maxFps / 2) {
|
||||||
|
const dropped = this.framesList.length;
|
||||||
|
this.framesList = [];
|
||||||
|
this.videoStats.push({
|
||||||
|
decodedFrames: 0,
|
||||||
|
droppedFrames: dropped,
|
||||||
|
inputBytes: 0,
|
||||||
|
inputFrames: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.framesList.push(frame);
|
||||||
|
this.shiftFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clearState(): void {
|
||||||
|
this.framesList = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
577
src/app/player/BasePlayer.ts
Normal file
577
src/app/player/BasePlayer.ts
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import ScreenInfo from '../ScreenInfo';
|
||||||
|
import Rect from '../Rect';
|
||||||
|
import Size from '../Size';
|
||||||
|
import Util from '../Util';
|
||||||
|
import { TypedEmitter } from '../../common/TypedEmitter';
|
||||||
|
import { DisplayInfo } from '../DisplayInfo';
|
||||||
|
|
||||||
|
interface BitrateStat {
|
||||||
|
timestamp: number;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FramesPerSecondStats {
|
||||||
|
avgInput: number;
|
||||||
|
avgDecoded: number;
|
||||||
|
avgDropped: number;
|
||||||
|
avgSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackQuality {
|
||||||
|
decodedFrames: number;
|
||||||
|
droppedFrames: number;
|
||||||
|
inputFrames: number;
|
||||||
|
inputBytes: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerEvents {
|
||||||
|
'video-view-resize': Size;
|
||||||
|
'input-video-resize': ScreenInfo;
|
||||||
|
'video-settings': VideoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerClass {
|
||||||
|
playerFullName: string;
|
||||||
|
playerCodeName: string;
|
||||||
|
storageKeyPrefix: string;
|
||||||
|
isSupported(): boolean;
|
||||||
|
getPreferredVideoSetting(): VideoSettings;
|
||||||
|
getFitToScreenStatus(deviceName: string, displayInfo?: DisplayInfo): boolean;
|
||||||
|
loadVideoSettings(deviceName: string, displayInfo?: DisplayInfo): VideoSettings;
|
||||||
|
saveVideoSettings(
|
||||||
|
deviceName: string,
|
||||||
|
videoSettings: VideoSettings,
|
||||||
|
fitToScreen: boolean,
|
||||||
|
displayInfo?: DisplayInfo,
|
||||||
|
): void;
|
||||||
|
new(udid: string, displayInfo?: DisplayInfo): BasePlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BasePlayer extends TypedEmitter<PlayerEvents> {
|
||||||
|
private static readonly STAT_BACKGROUND: string = 'rgba(0, 0, 0, 0.5)';
|
||||||
|
private static readonly STAT_TEXT_COLOR: string = 'hsl(24, 85%, 50%)';
|
||||||
|
public static readonly DEFAULT_SHOW_QUALITY_STATS = false;
|
||||||
|
public static STATE: Record<string, number> = {
|
||||||
|
PLAYING: 1,
|
||||||
|
PAUSED: 2,
|
||||||
|
STOPPED: 3,
|
||||||
|
};
|
||||||
|
private static STATS_HEIGHT = 12;
|
||||||
|
protected screenInfo?: ScreenInfo;
|
||||||
|
protected videoSettings: VideoSettings;
|
||||||
|
protected parentElement?: HTMLElement;
|
||||||
|
protected touchableCanvas: HTMLCanvasElement;
|
||||||
|
protected inputBytes: BitrateStat[] = [];
|
||||||
|
protected perSecondQualityStats?: FramesPerSecondStats;
|
||||||
|
protected momentumQualityStats?: PlaybackQuality;
|
||||||
|
protected bounds: Size | null = null;
|
||||||
|
private totalStats: PlaybackQuality = {
|
||||||
|
decodedFrames: 0,
|
||||||
|
droppedFrames: 0,
|
||||||
|
inputFrames: 0,
|
||||||
|
inputBytes: 0,
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
private totalStatsCounter = 0;
|
||||||
|
private dirtyStatsWidth = 0;
|
||||||
|
private state: number = BasePlayer.STATE.STOPPED;
|
||||||
|
private qualityAnimationId?: number;
|
||||||
|
private showQualityStats = BasePlayer.DEFAULT_SHOW_QUALITY_STATS;
|
||||||
|
protected receivedFirstFrame = false;
|
||||||
|
private statLines: string[] = [];
|
||||||
|
public readonly supportsScreenshot: boolean = false;
|
||||||
|
public readonly resizeVideoToBounds: boolean = false;
|
||||||
|
protected videoHeight = -1;
|
||||||
|
protected videoWidth = -1;
|
||||||
|
|
||||||
|
public static storageKeyPrefix = 'BaseDecoder';
|
||||||
|
public static playerFullName = 'BasePlayer';
|
||||||
|
public static playerCodeName = 'baseplayer';
|
||||||
|
public static preferredVideoSettings: VideoSettings = new VideoSettings({
|
||||||
|
lockedVideoOrientation: -1,
|
||||||
|
bitrate: 524288,
|
||||||
|
maxFps: 24,
|
||||||
|
iFrameInterval: 5,
|
||||||
|
bounds: new Size(480, 480),
|
||||||
|
sendFrameMeta: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
public static isSupported(): boolean {
|
||||||
|
// Implement the check in a child class
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly udid: string,
|
||||||
|
protected displayInfo?: DisplayInfo,
|
||||||
|
protected name: string = 'BasePlayer',
|
||||||
|
protected storageKeyPrefix: string = 'Dummy',
|
||||||
|
protected tag: HTMLElement = document.createElement('div'),
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.touchableCanvas = document.createElement('canvas');
|
||||||
|
this.touchableCanvas.className = 'touch-layer';
|
||||||
|
this.touchableCanvas.oncontextmenu = function (event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
const preferred = this.getPreferredVideoSetting();
|
||||||
|
this.videoSettings = BasePlayer.getVideoSettingFromStorage(preferred, this.storageKeyPrefix, udid, displayInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected calculateScreenInfoForBounds(videoWidth: number, videoHeight: number): void {
|
||||||
|
this.videoWidth = videoWidth;
|
||||||
|
this.videoHeight = videoHeight;
|
||||||
|
if (this.resizeVideoToBounds) {
|
||||||
|
let w = videoWidth;
|
||||||
|
let h = videoHeight;
|
||||||
|
if (this.bounds) {
|
||||||
|
let { w: boundsWidth, h: boundsHeight } = this.bounds;
|
||||||
|
if (w > boundsWidth || h > boundsHeight) {
|
||||||
|
let scaledHeight;
|
||||||
|
let scaledWidth;
|
||||||
|
if (boundsWidth > w) {
|
||||||
|
scaledHeight = h;
|
||||||
|
} else {
|
||||||
|
scaledHeight = (boundsWidth * h) / w;
|
||||||
|
}
|
||||||
|
if (boundsHeight > scaledHeight) {
|
||||||
|
boundsHeight = scaledHeight;
|
||||||
|
}
|
||||||
|
if (boundsHeight == h) {
|
||||||
|
scaledWidth = w;
|
||||||
|
} else {
|
||||||
|
scaledWidth = (boundsHeight * w) / h;
|
||||||
|
}
|
||||||
|
if (boundsWidth > scaledWidth) {
|
||||||
|
boundsWidth = scaledWidth;
|
||||||
|
}
|
||||||
|
w = boundsWidth | 0;
|
||||||
|
h = boundsHeight | 0;
|
||||||
|
this.tag.style.maxWidth = `${w}px`;
|
||||||
|
this.tag.style.maxHeight = `${h}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const realScreen = new ScreenInfo(new Rect(0, 0, videoWidth, videoHeight), new Size(w, h), 0);
|
||||||
|
this.emit('input-video-resize', realScreen);
|
||||||
|
this.setScreenInfo(new ScreenInfo(new Rect(0, 0, w, h), new Size(w, h), 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static isIFrame(frame: Uint8Array): boolean {
|
||||||
|
// last 5 bits === 5: Coded slice of an IDR picture
|
||||||
|
|
||||||
|
// https://www.ietf.org/rfc/rfc3984.txt
|
||||||
|
// 1.3. Network Abstraction Layer Unit Types
|
||||||
|
// https://www.itu.int/rec/T-REC-H.264-201906-I/en
|
||||||
|
// Table 7-1 – NAL unit type codes, syntax element categories, and NAL unit type classes
|
||||||
|
return frame && frame.length > 4 && (frame[4] & 31) === 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getStorageKey(storageKeyPrefix: string, udid: string): string {
|
||||||
|
const { innerHeight, innerWidth } = window;
|
||||||
|
return `${storageKeyPrefix}:${udid}:${innerWidth}x${innerHeight}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getFullStorageKey(storageKeyPrefix: string, udid: string, displayInfo?: DisplayInfo): string {
|
||||||
|
const { innerHeight, innerWidth } = window;
|
||||||
|
let base = `${storageKeyPrefix}:${udid}:${innerWidth}x${innerHeight}`;
|
||||||
|
if (displayInfo) {
|
||||||
|
const { displayId, size } = displayInfo;
|
||||||
|
base = `${base}:${displayId}:${size.width}x${size.height}`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getFromStorageCompat(prefix: string, udid: string, displayInfo?: DisplayInfo): string | null {
|
||||||
|
const shortKey = this.getStorageKey(prefix, udid);
|
||||||
|
const savedInShort = window.localStorage.getItem(shortKey);
|
||||||
|
if (!displayInfo) {
|
||||||
|
return savedInShort;
|
||||||
|
}
|
||||||
|
const isDefaultDisplay = displayInfo.displayId === DisplayInfo.DEFAULT_DISPLAY;
|
||||||
|
const fullKey = this.getFullStorageKey(prefix, udid, displayInfo);
|
||||||
|
const savedInFull = window.localStorage.getItem(fullKey);
|
||||||
|
if (savedInFull) {
|
||||||
|
if (savedInShort && isDefaultDisplay) {
|
||||||
|
window.localStorage.removeItem(shortKey);
|
||||||
|
}
|
||||||
|
return savedInFull;
|
||||||
|
}
|
||||||
|
if (isDefaultDisplay) {
|
||||||
|
return savedInShort;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getFitToScreenFromStorage(
|
||||||
|
storageKeyPrefix: string,
|
||||||
|
udid: string,
|
||||||
|
displayInfo?: DisplayInfo,
|
||||||
|
): boolean {
|
||||||
|
if (!window.localStorage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let parsedValue = false;
|
||||||
|
const key = `${this.getFullStorageKey(storageKeyPrefix, udid, displayInfo)}:fit`;
|
||||||
|
const saved = window.localStorage.getItem(key);
|
||||||
|
if (!saved) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parsedValue = JSON.parse(saved);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[${this.name}]`, 'Failed to parse', saved);
|
||||||
|
}
|
||||||
|
return parsedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getVideoSettingFromStorage(
|
||||||
|
preferred: VideoSettings,
|
||||||
|
storageKeyPrefix: string,
|
||||||
|
udid: string,
|
||||||
|
displayInfo?: DisplayInfo,
|
||||||
|
): VideoSettings {
|
||||||
|
if (!window.localStorage) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
const saved = this.getFromStorageCompat(storageKeyPrefix, udid, displayInfo);
|
||||||
|
if (!saved) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
const {
|
||||||
|
displayId,
|
||||||
|
crop,
|
||||||
|
bitrate,
|
||||||
|
iFrameInterval,
|
||||||
|
sendFrameMeta,
|
||||||
|
lockedVideoOrientation,
|
||||||
|
codecOptions,
|
||||||
|
encoderName,
|
||||||
|
} = parsed;
|
||||||
|
|
||||||
|
// REMOVE `frameRate`
|
||||||
|
const maxFps = isNaN(parsed.maxFps) ? parsed.frameRate : parsed.maxFps;
|
||||||
|
// REMOVE `maxSize`
|
||||||
|
let bounds: Size | null = null;
|
||||||
|
if (typeof parsed.bounds !== 'object' || isNaN(parsed.bounds.width) || isNaN(parsed.bounds.height)) {
|
||||||
|
if (!isNaN(parsed.maxSize)) {
|
||||||
|
bounds = new Size(parsed.maxSize, parsed.maxSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bounds = new Size(parsed.bounds.width, parsed.bounds.height);
|
||||||
|
}
|
||||||
|
return new VideoSettings({
|
||||||
|
displayId: typeof displayId === 'number' ? displayId : 0,
|
||||||
|
crop: crop ? new Rect(crop.left, crop.top, crop.right, crop.bottom) : preferred.crop,
|
||||||
|
bitrate: !isNaN(bitrate) ? bitrate : preferred.bitrate,
|
||||||
|
bounds: bounds !== null ? bounds : preferred.bounds,
|
||||||
|
maxFps: !isNaN(maxFps) ? maxFps : preferred.maxFps,
|
||||||
|
iFrameInterval: !isNaN(iFrameInterval) ? iFrameInterval : preferred.iFrameInterval,
|
||||||
|
sendFrameMeta: typeof sendFrameMeta === 'boolean' ? sendFrameMeta : preferred.sendFrameMeta,
|
||||||
|
lockedVideoOrientation: !isNaN(lockedVideoOrientation)
|
||||||
|
? lockedVideoOrientation
|
||||||
|
: preferred.lockedVideoOrientation,
|
||||||
|
codecOptions,
|
||||||
|
encoderName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static putVideoSettingsToStorage(
|
||||||
|
storageKeyPrefix: string,
|
||||||
|
udid: string,
|
||||||
|
videoSettings: VideoSettings,
|
||||||
|
fitToScreen: boolean,
|
||||||
|
displayInfo?: DisplayInfo,
|
||||||
|
): void {
|
||||||
|
if (!window.localStorage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = this.getFullStorageKey(storageKeyPrefix, udid, displayInfo);
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(videoSettings));
|
||||||
|
const fitKey = `${key}:fit`;
|
||||||
|
window.localStorage.setItem(fitKey, JSON.stringify(fitToScreen));
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract getImageDataURL(): string;
|
||||||
|
|
||||||
|
public createScreenshot(deviceName: string): void {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = this.getImageDataURL();
|
||||||
|
a.download = `${deviceName} ${new Date().toLocaleString()}.png`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public play(): void {
|
||||||
|
if (this.needScreenInfoBeforePlay() && !this.screenInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state = BasePlayer.STATE.PLAYING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public pause(): void {
|
||||||
|
this.state = BasePlayer.STATE.PAUSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
this.state = BasePlayer.STATE.STOPPED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState(): number {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public pushFrame(frame: Uint8Array): void {
|
||||||
|
if (!this.receivedFirstFrame) {
|
||||||
|
this.receivedFirstFrame = true;
|
||||||
|
if (typeof this.qualityAnimationId !== 'number') {
|
||||||
|
this.qualityAnimationId = requestAnimationFrame(this.updateQualityStats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.inputBytes.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
bytes: frame.byteLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract getPreferredVideoSetting(): VideoSettings;
|
||||||
|
protected abstract calculateMomentumStats(): void;
|
||||||
|
|
||||||
|
public getTouchableElement(): HTMLCanvasElement {
|
||||||
|
return this.touchableCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setParent(parent: HTMLElement): void {
|
||||||
|
this.parentElement = parent;
|
||||||
|
parent.appendChild(this.tag);
|
||||||
|
parent.appendChild(this.touchableCanvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected needScreenInfoBeforePlay(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getVideoSettings(): VideoSettings {
|
||||||
|
return this.videoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean, saveToStorage: boolean): void {
|
||||||
|
this.videoSettings = videoSettings;
|
||||||
|
if (saveToStorage) {
|
||||||
|
BasePlayer.putVideoSettingsToStorage(
|
||||||
|
this.storageKeyPrefix,
|
||||||
|
this.udid,
|
||||||
|
videoSettings,
|
||||||
|
fitToScreen,
|
||||||
|
this.displayInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.resetStats();
|
||||||
|
this.emit('video-settings', VideoSettings.copy(videoSettings));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getScreenInfo(): ScreenInfo | undefined {
|
||||||
|
return this.screenInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setScreenInfo(screenInfo: ScreenInfo): void {
|
||||||
|
if (this.needScreenInfoBeforePlay()) {
|
||||||
|
this.pause();
|
||||||
|
}
|
||||||
|
this.receivedFirstFrame = false;
|
||||||
|
this.screenInfo = screenInfo;
|
||||||
|
const { width, height } = screenInfo.videoSize;
|
||||||
|
this.touchableCanvas.width = width;
|
||||||
|
this.touchableCanvas.height = height;
|
||||||
|
if (this.parentElement) {
|
||||||
|
this.parentElement.style.height = `${height}px`;
|
||||||
|
this.parentElement.style.width = `${width}px`;
|
||||||
|
}
|
||||||
|
const size = new Size(width, height);
|
||||||
|
this.emit('video-view-resize', size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): string {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected resetStats(): void {
|
||||||
|
this.receivedFirstFrame = false;
|
||||||
|
this.totalStatsCounter = 0;
|
||||||
|
this.totalStats = {
|
||||||
|
droppedFrames: 0,
|
||||||
|
decodedFrames: 0,
|
||||||
|
inputFrames: 0,
|
||||||
|
inputBytes: 0,
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
this.perSecondQualityStats = {
|
||||||
|
avgDecoded: 0,
|
||||||
|
avgDropped: 0,
|
||||||
|
avgInput: 0,
|
||||||
|
avgSize: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateQualityStats = (): void => {
|
||||||
|
const now = Date.now();
|
||||||
|
const oneSecondBefore = now - 1000;
|
||||||
|
this.calculateMomentumStats();
|
||||||
|
if (!this.momentumQualityStats) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.totalStats.timestamp < oneSecondBefore) {
|
||||||
|
this.totalStats = {
|
||||||
|
timestamp: now,
|
||||||
|
decodedFrames: this.totalStats.decodedFrames + this.momentumQualityStats.decodedFrames,
|
||||||
|
droppedFrames: this.totalStats.droppedFrames + this.momentumQualityStats.droppedFrames,
|
||||||
|
inputFrames: this.totalStats.inputFrames + this.momentumQualityStats.inputFrames,
|
||||||
|
inputBytes: this.totalStats.inputBytes + this.momentumQualityStats.inputBytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.totalStatsCounter !== 0) {
|
||||||
|
this.perSecondQualityStats = {
|
||||||
|
avgDecoded: this.totalStats.decodedFrames / this.totalStatsCounter,
|
||||||
|
avgDropped: this.totalStats.droppedFrames / this.totalStatsCounter,
|
||||||
|
avgInput: this.totalStats.inputFrames / this.totalStatsCounter,
|
||||||
|
avgSize: this.totalStats.inputBytes / this.totalStatsCounter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.totalStatsCounter++;
|
||||||
|
}
|
||||||
|
this.drawStats();
|
||||||
|
if (this.state !== BasePlayer.STATE.STOPPED) {
|
||||||
|
this.qualityAnimationId = requestAnimationFrame(this.updateQualityStats);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private drawStats(): void {
|
||||||
|
if (!this.showQualityStats) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = this.touchableCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newStats = [];
|
||||||
|
if (this.perSecondQualityStats && this.momentumQualityStats) {
|
||||||
|
const { decodedFrames, droppedFrames, inputBytes, inputFrames } = this.momentumQualityStats;
|
||||||
|
const { avgDecoded, avgDropped, avgSize, avgInput } = this.perSecondQualityStats;
|
||||||
|
const padInput = inputFrames.toString().padStart(3, ' ');
|
||||||
|
const padDecoded = decodedFrames.toString().padStart(3, ' ');
|
||||||
|
const padDropped = droppedFrames.toString().padStart(3, ' ');
|
||||||
|
const padAvgDecoded = avgDecoded.toFixed(1).padStart(5, ' ');
|
||||||
|
const padAvgDropped = avgDropped.toFixed(1).padStart(5, ' ');
|
||||||
|
const padAvgInput = avgInput.toFixed(1).padStart(5, ' ');
|
||||||
|
const prettyBytes = Util.prettyBytes(inputBytes).padStart(8, ' ');
|
||||||
|
const prettyAvgBytes = Util.prettyBytes(avgSize).padStart(8, ' ');
|
||||||
|
|
||||||
|
newStats.push(`Input bytes: ${prettyBytes} (avg: ${prettyAvgBytes}/s)`);
|
||||||
|
newStats.push(`Input FPS: ${padInput} (avg: ${padAvgInput})`);
|
||||||
|
newStats.push(`Dropped FPS: ${padDropped} (avg: ${padAvgDropped})`);
|
||||||
|
newStats.push(`Decoded FPS: ${padDecoded} (avg: ${padAvgDecoded})`);
|
||||||
|
} else {
|
||||||
|
newStats.push(`Not supported`);
|
||||||
|
}
|
||||||
|
let changed = this.statLines.length !== newStats.length;
|
||||||
|
let i = 0;
|
||||||
|
while (!changed && i++ < newStats.length) {
|
||||||
|
if (newStats[i] !== this.statLines[i]) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
this.statLines = newStats;
|
||||||
|
this.updateCanvas(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCanvas(onlyClear: boolean): void {
|
||||||
|
const ctx = this.touchableCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("123")
|
||||||
|
const y = this.touchableCanvas.height;
|
||||||
|
const height = BasePlayer.STATS_HEIGHT;
|
||||||
|
const lines = this.statLines.length;
|
||||||
|
const spaces = lines + 1;
|
||||||
|
const p = height / 2;
|
||||||
|
const d = p * 2;
|
||||||
|
const totalHeight = height * lines + p * spaces;
|
||||||
|
|
||||||
|
ctx.clearRect(0, y - totalHeight, this.dirtyStatsWidth + d, totalHeight);
|
||||||
|
this.dirtyStatsWidth = 0;
|
||||||
|
|
||||||
|
if (onlyClear) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${height}px monospace`;
|
||||||
|
this.statLines.forEach((text) => {
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
const dirty = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
|
||||||
|
this.dirtyStatsWidth = Math.max(dirty, this.dirtyStatsWidth);
|
||||||
|
});
|
||||||
|
ctx.fillStyle = BasePlayer.STAT_BACKGROUND;
|
||||||
|
ctx.fillRect(0, y - totalHeight, this.dirtyStatsWidth + d, totalHeight);
|
||||||
|
ctx.fillStyle = BasePlayer.STAT_TEXT_COLOR;
|
||||||
|
this.statLines.forEach((text, line) => {
|
||||||
|
ctx.fillText(text, p, y - p - line * (height + p));
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setShowQualityStats(value: boolean): void {
|
||||||
|
this.showQualityStats = value;
|
||||||
|
if (!value) {
|
||||||
|
this.updateCanvas(true);
|
||||||
|
} else {
|
||||||
|
this.drawStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getShowQualityStats(): boolean {
|
||||||
|
return this.showQualityStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setBounds(bounds: Size): void {
|
||||||
|
this.bounds = Size.copy(bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDisplayInfo(): DisplayInfo | undefined {
|
||||||
|
return this.displayInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setDisplayInfo(displayInfo: DisplayInfo): void {
|
||||||
|
this.displayInfo = displayInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract getFitToScreenStatus(): boolean;
|
||||||
|
|
||||||
|
public abstract loadVideoSettings(): VideoSettings;
|
||||||
|
|
||||||
|
public static loadVideoSettings(udid: string, displayInfo?: DisplayInfo): VideoSettings {
|
||||||
|
return this.getVideoSettingFromStorage(this.preferredVideoSettings, this.storageKeyPrefix, udid, displayInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getFitToScreenStatus(udid: string, displayInfo?: DisplayInfo): boolean {
|
||||||
|
return this.getFitToScreenFromStorage(this.storageKeyPrefix, udid, displayInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getPreferredVideoSetting(): VideoSettings {
|
||||||
|
return this.preferredVideoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static saveVideoSettings(
|
||||||
|
udid: string,
|
||||||
|
videoSettings: VideoSettings,
|
||||||
|
fitToScreen: boolean,
|
||||||
|
displayInfo?: DisplayInfo,
|
||||||
|
): void {
|
||||||
|
this.putVideoSettingsToStorage(this.storageKeyPrefix, udid, videoSettings, fitToScreen, displayInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/app/player/BroadwayPlayer.ts
Normal file
69
src/app/player/BroadwayPlayer.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import '../../../vendor/Broadway/avc.wasm.asset';
|
||||||
|
import { BaseCanvasBasedPlayer } from './BaseCanvasBasedPlayer';
|
||||||
|
import Size from '../Size';
|
||||||
|
import YUVCanvas from '../../../vendor/h264-live-player/YUVCanvas';
|
||||||
|
import YUVWebGLCanvas from '../../../vendor/h264-live-player/YUVWebGLCanvas';
|
||||||
|
import Avc from '../../../vendor/Broadway/Decoder';
|
||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import Canvas from '../../../vendor/h264-live-player/Canvas';
|
||||||
|
import { DisplayInfo } from '../DisplayInfo';
|
||||||
|
|
||||||
|
export class BroadwayPlayer extends BaseCanvasBasedPlayer {
|
||||||
|
public static readonly storageKeyPrefix = 'BroadwayDecoder';
|
||||||
|
public static readonly playerFullName = 'Broadway.js';
|
||||||
|
public static readonly playerCodeName = 'broadway';
|
||||||
|
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
|
||||||
|
lockedVideoOrientation: -1,
|
||||||
|
bitrate: 524288,
|
||||||
|
maxFps: 24,
|
||||||
|
iFrameInterval: 5,
|
||||||
|
bounds: new Size(480, 480),
|
||||||
|
sendFrameMeta: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
protected canvas?: Canvas;
|
||||||
|
private avc?: Avc;
|
||||||
|
public readonly supportsScreenshot: boolean = true;
|
||||||
|
|
||||||
|
public static isSupported(): boolean {
|
||||||
|
return typeof WebAssembly === 'object' && typeof WebAssembly.instantiate === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(udid: string, displayInfo?: DisplayInfo, name = BroadwayPlayer.playerFullName) {
|
||||||
|
super(udid, displayInfo, name, BroadwayPlayer.storageKeyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected initCanvas(width: number, height: number): void {
|
||||||
|
super.initCanvas(width, height);
|
||||||
|
if (BaseCanvasBasedPlayer.hasWebGLSupport()) {
|
||||||
|
this.canvas = new YUVWebGLCanvas(this.tag, new Size(width, height));
|
||||||
|
} else {
|
||||||
|
this.canvas = new YUVCanvas(this.tag, new Size(width, height));
|
||||||
|
}
|
||||||
|
if (!this.avc) {
|
||||||
|
this.avc = new Avc();
|
||||||
|
}
|
||||||
|
this.avc.onPictureDecoded = (buffer: Uint8Array, width: number, height: number) => {
|
||||||
|
this.onFrameDecoded(width, height, buffer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected decode(data: Uint8Array): void {
|
||||||
|
if (!this.avc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.avc.decode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPreferredVideoSetting(): VideoSettings {
|
||||||
|
return BroadwayPlayer.preferredVideoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getFitToScreenStatus(): boolean {
|
||||||
|
return BroadwayPlayer.getFitToScreenStatus(this.udid, this.displayInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadVideoSettings(): VideoSettings {
|
||||||
|
return BroadwayPlayer.loadVideoSettings(this.udid, this.displayInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/app/player/MjpegPlayer.ts
Normal file
93
src/app/player/MjpegPlayer.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { BasePlayer } from './BasePlayer';
|
||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import { DisplayInfo } from '../DisplayInfo';
|
||||||
|
|
||||||
|
export class MjpegPlayer extends BasePlayer {
|
||||||
|
private static dummyVideoSettings = new VideoSettings();
|
||||||
|
public static storageKeyPrefix = 'MjpegDecoder';
|
||||||
|
public static playerFullName = 'Mjpeg Http Player';
|
||||||
|
public static playerCodeName = 'mjpeghttp';
|
||||||
|
|
||||||
|
public static createElement(id?: string): HTMLImageElement {
|
||||||
|
const tag = document.createElement('img') as HTMLImageElement;
|
||||||
|
if (typeof id === 'string') {
|
||||||
|
tag.id = id;
|
||||||
|
}
|
||||||
|
tag.className = 'video-layer';
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isSupported(): boolean {
|
||||||
|
// I guess everything supports MJPEG?
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly supportsScreenshot = true;
|
||||||
|
public readonly resizeVideoToBounds: boolean = true;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
udid: string,
|
||||||
|
displayInfo?: DisplayInfo,
|
||||||
|
name = 'MJPEG_Player',
|
||||||
|
storageKeyPrefix = 'MJPEG',
|
||||||
|
protected tag: HTMLImageElement = MjpegPlayer.createElement(),
|
||||||
|
) {
|
||||||
|
super(udid, displayInfo, name, storageKeyPrefix, tag);
|
||||||
|
this.tag.onload = () => {
|
||||||
|
this.checkVideoResize();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public play(): void {
|
||||||
|
super.play();
|
||||||
|
this.tag.setAttribute('src', `${location.protocol}//${location.host}/mjpeg/${this.udid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public pause(): void {
|
||||||
|
super.pause();
|
||||||
|
this.tag.removeAttribute('src');
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
super.stop();
|
||||||
|
this.tag.removeAttribute('src');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected needScreenInfoBeforePlay(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected calculateMomentumStats(): void {
|
||||||
|
// not implemented
|
||||||
|
}
|
||||||
|
|
||||||
|
getFitToScreenStatus(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getImageDataURL(): string {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = this.videoWidth;
|
||||||
|
canvas.height = this.videoHeight;
|
||||||
|
canvas.getContext('2d')?.drawImage(this.tag, 0, 0);
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreferredVideoSetting(): VideoSettings {
|
||||||
|
return MjpegPlayer.dummyVideoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVideoSettings(): VideoSettings {
|
||||||
|
return MjpegPlayer.dummyVideoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkVideoResize = (): void => {
|
||||||
|
if (!this.tag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { height, width } = this.tag;
|
||||||
|
if (this.videoHeight !== height || this.videoWidth !== width) {
|
||||||
|
this.calculateScreenInfoForBounds(width, height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
450
src/app/player/MsePlayer.ts
Normal file
450
src/app/player/MsePlayer.ts
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
import { BasePlayer } from './BasePlayer';
|
||||||
|
import VideoConverter, { setLogger, mimeType } from 'h264-converter';
|
||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import Size from '../Size';
|
||||||
|
import { DisplayInfo } from '../DisplayInfo';
|
||||||
|
|
||||||
|
interface QualityStats {
|
||||||
|
timestamp: number;
|
||||||
|
decodedFrames: number;
|
||||||
|
droppedFrames: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MsePlayer extends BasePlayer {
|
||||||
|
public static readonly storageKeyPrefix = 'MseDecoder';
|
||||||
|
public static readonly playerFullName = 'H264 Converter';
|
||||||
|
public static readonly playerCodeName = 'mse';
|
||||||
|
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
|
||||||
|
lockedVideoOrientation: -1,
|
||||||
|
bitrate: 7340032,
|
||||||
|
maxFps: 60,
|
||||||
|
iFrameInterval: 10,
|
||||||
|
bounds: new Size(720, 720),
|
||||||
|
sendFrameMeta: false,
|
||||||
|
});
|
||||||
|
private static DEFAULT_FRAMES_PER_FRAGMENT = 1;
|
||||||
|
private static DEFAULT_FRAMES_PER_SECOND = 60;
|
||||||
|
|
||||||
|
public static createElement(id?: string): HTMLVideoElement {
|
||||||
|
const tag = document.createElement('video') as HTMLVideoElement;
|
||||||
|
tag.muted = true;
|
||||||
|
tag.autoplay = true;
|
||||||
|
tag.setAttribute('muted', 'muted');
|
||||||
|
tag.setAttribute('autoplay', 'autoplay');
|
||||||
|
if (typeof id === 'string') {
|
||||||
|
tag.id = id;
|
||||||
|
}
|
||||||
|
tag.className = 'video-layer';
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
private converter?: VideoConverter;
|
||||||
|
private videoStats: QualityStats[] = [];
|
||||||
|
private noDecodedFramesSince = -1;
|
||||||
|
private currentTimeNotChangedSince = -1;
|
||||||
|
private bigBufferSince = -1;
|
||||||
|
private aheadOfBufferSince = -1;
|
||||||
|
public fpf: number = MsePlayer.DEFAULT_FRAMES_PER_FRAGMENT;
|
||||||
|
public readonly supportsScreenshot = true;
|
||||||
|
private sourceBuffer?: SourceBuffer;
|
||||||
|
private waitUntilSegmentRemoved = false;
|
||||||
|
private blocks: Block[] = [];
|
||||||
|
private frames: Uint8Array[] = [];
|
||||||
|
private jumpEnd = -1;
|
||||||
|
private lastTime = -1;
|
||||||
|
protected canPlay = false;
|
||||||
|
private seekingSince = -1;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
protected readonly isSafari = !!(window as unknown as any)['safari'];
|
||||||
|
protected readonly isChrome = navigator.userAgent.includes('Chrome');
|
||||||
|
protected readonly isMac = navigator.platform.startsWith('Mac');
|
||||||
|
private MAX_TIME_TO_RECOVER = 200; // ms
|
||||||
|
private MAX_BUFFER = this.isSafari ? 2 : this.isChrome && this.isMac ? 0.9 : 0.2;
|
||||||
|
private MAX_AHEAD = -0.2;
|
||||||
|
|
||||||
|
public static isSupported(): boolean {
|
||||||
|
return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
udid: string,
|
||||||
|
displayInfo?: DisplayInfo,
|
||||||
|
name = MsePlayer.playerFullName,
|
||||||
|
protected tag: HTMLVideoElement = MsePlayer.createElement(),
|
||||||
|
) {
|
||||||
|
super(udid, displayInfo, name, MsePlayer.storageKeyPrefix, tag);
|
||||||
|
tag.oncontextmenu = function (event: MouseEvent): boolean {
|
||||||
|
event.preventDefault();
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
tag.addEventListener('error', this.onVideoError);
|
||||||
|
tag.addEventListener('canplay', this.onVideoCanPlay);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
setLogger(() => {}, console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
onVideoError = (event: Event): void => {
|
||||||
|
console.error(`[${this.name}]`, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
onVideoCanPlay = (): void => {
|
||||||
|
this.onCanPlayHandler();
|
||||||
|
};
|
||||||
|
|
||||||
|
private static createConverter(
|
||||||
|
tag: HTMLVideoElement,
|
||||||
|
fps: number = MsePlayer.DEFAULT_FRAMES_PER_SECOND,
|
||||||
|
fpf: number = MsePlayer.DEFAULT_FRAMES_PER_FRAGMENT,
|
||||||
|
): VideoConverter {
|
||||||
|
return new VideoConverter(tag, fps, fpf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVideoPlaybackQuality(): QualityStats | null {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const video = this.tag as any;
|
||||||
|
if (typeof video.mozDecodedFrames !== 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (typeof this.tag.getVideoPlaybackQuality == 'function') {
|
||||||
|
const temp = this.tag.getVideoPlaybackQuality();
|
||||||
|
return {
|
||||||
|
timestamp: now,
|
||||||
|
decodedFrames: temp.totalVideoFrames,
|
||||||
|
droppedFrames: temp.droppedVideoFrames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webkit-specific properties
|
||||||
|
if (typeof video.webkitDecodedFrameCount !== 'undefined') {
|
||||||
|
return {
|
||||||
|
timestamp: now,
|
||||||
|
decodedFrames: video.webkitDecodedFrameCount,
|
||||||
|
droppedFrames: video.webkitDroppedFrameCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onCanPlayHandler(): void {
|
||||||
|
this.canPlay = true;
|
||||||
|
this.tag.play();
|
||||||
|
this.tag.removeEventListener('canplay', this.onVideoCanPlay);
|
||||||
|
this.checkVideoResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected calculateMomentumStats(): void {
|
||||||
|
const stat = this.getVideoPlaybackQuality();
|
||||||
|
if (!stat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const oneSecondBefore = timestamp - 1000;
|
||||||
|
this.videoStats.push(stat);
|
||||||
|
|
||||||
|
while (this.videoStats.length && this.videoStats[0].timestamp < oneSecondBefore) {
|
||||||
|
this.videoStats.shift();
|
||||||
|
}
|
||||||
|
while (this.inputBytes.length && this.inputBytes[0].timestamp < oneSecondBefore) {
|
||||||
|
this.inputBytes.shift();
|
||||||
|
}
|
||||||
|
let inputBytes = 0;
|
||||||
|
this.inputBytes.forEach((item) => {
|
||||||
|
inputBytes += item.bytes;
|
||||||
|
});
|
||||||
|
const inputFrames = this.inputBytes.length;
|
||||||
|
if (this.videoStats.length) {
|
||||||
|
const oldest = this.videoStats[0];
|
||||||
|
const decodedFrames = stat.decodedFrames - oldest.decodedFrames;
|
||||||
|
const droppedFrames = stat.droppedFrames - oldest.droppedFrames;
|
||||||
|
// const droppedFrames = inputFrames - decodedFrames;
|
||||||
|
this.momentumQualityStats = {
|
||||||
|
decodedFrames,
|
||||||
|
droppedFrames,
|
||||||
|
inputBytes,
|
||||||
|
inputFrames,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected resetStats(): void {
|
||||||
|
super.resetStats();
|
||||||
|
this.videoStats = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getImageDataURL(): string {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = this.tag.clientWidth;
|
||||||
|
canvas.height = this.tag.clientHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(this.tag, 0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas.toDataURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
public play(): void {
|
||||||
|
super.play();
|
||||||
|
if (this.getState() !== BasePlayer.STATE.PLAYING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.converter) {
|
||||||
|
let fps = MsePlayer.DEFAULT_FRAMES_PER_SECOND;
|
||||||
|
if (this.videoSettings) {
|
||||||
|
fps = this.videoSettings.maxFps;
|
||||||
|
}
|
||||||
|
this.converter = MsePlayer.createConverter(this.tag, fps, this.fpf);
|
||||||
|
this.canPlay = false;
|
||||||
|
this.resetStats();
|
||||||
|
}
|
||||||
|
this.converter.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
public pause(): void {
|
||||||
|
super.pause();
|
||||||
|
this.stopConverter();
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
super.stop();
|
||||||
|
this.stopConverter();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean, saveToStorage: boolean): void {
|
||||||
|
if (this.videoSettings && this.videoSettings.maxFps !== videoSettings.maxFps) {
|
||||||
|
const state = this.getState();
|
||||||
|
if (this.converter) {
|
||||||
|
this.stop();
|
||||||
|
this.converter = MsePlayer.createConverter(this.tag, videoSettings.maxFps, this.fpf);
|
||||||
|
this.canPlay = false;
|
||||||
|
}
|
||||||
|
if (state === BasePlayer.STATE.PLAYING) {
|
||||||
|
this.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.setVideoSettings(videoSettings, fitToScreen, saveToStorage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPreferredVideoSetting(): VideoSettings {
|
||||||
|
return MsePlayer.preferredVideoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkVideoResize = (): void => {
|
||||||
|
if (!this.tag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { videoHeight, videoWidth } = this.tag;
|
||||||
|
if (this.videoHeight !== videoHeight || this.videoWidth !== videoWidth) {
|
||||||
|
this.calculateScreenInfoForBounds(videoWidth, videoHeight);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cleanSourceBuffer = (): void => {
|
||||||
|
if (!this.sourceBuffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.sourceBuffer.updating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.blocks.length < 10) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.sourceBuffer.removeEventListener('updateend', this.cleanSourceBuffer);
|
||||||
|
this.waitUntilSegmentRemoved = false;
|
||||||
|
const removeStart = this.blocks[0].start;
|
||||||
|
const removeEnd = this.blocks[4].end;
|
||||||
|
this.blocks = this.blocks.slice(5);
|
||||||
|
this.sourceBuffer.remove(removeStart, removeEnd);
|
||||||
|
let frame = this.frames.shift();
|
||||||
|
while (frame) {
|
||||||
|
if (!this.checkForIFrame(frame)) {
|
||||||
|
this.frames.unshift(frame);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
frame = this.frames.shift();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[${this.name}]`, 'Failed to clean source buffer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
jumpToEnd = (): void => {
|
||||||
|
if (!this.sourceBuffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.sourceBuffer.updating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.tag.buffered.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const end = this.tag.buffered.end(this.tag.seekable.length - 1);
|
||||||
|
console.log(`[${this.name}]`, `Jumping to the end (${this.jumpEnd}, ${end - this.jumpEnd}).`);
|
||||||
|
this.tag.currentTime = end;
|
||||||
|
this.jumpEnd = -1;
|
||||||
|
this.sourceBuffer.removeEventListener('updateend', this.jumpToEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
public pushFrame(frame: Uint8Array): void {
|
||||||
|
super.pushFrame(frame);
|
||||||
|
if (!this.checkForIFrame(frame)) {
|
||||||
|
this.frames.push(frame);
|
||||||
|
} else {
|
||||||
|
this.checkForBadState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected checkForBadState(): void {
|
||||||
|
// Workaround for stalled playback (`stalled` event is not fired, but the image freezes)
|
||||||
|
const { currentTime } = this.tag;
|
||||||
|
const now = Date.now();
|
||||||
|
// let reasonToJump = '';
|
||||||
|
let hasReasonToJump = false;
|
||||||
|
if (this.momentumQualityStats) {
|
||||||
|
if (this.momentumQualityStats.decodedFrames === 0 && this.momentumQualityStats.inputFrames > 0) {
|
||||||
|
if (this.noDecodedFramesSince === -1) {
|
||||||
|
this.noDecodedFramesSince = now;
|
||||||
|
} else {
|
||||||
|
const time = now - this.noDecodedFramesSince;
|
||||||
|
if (time > this.MAX_TIME_TO_RECOVER) {
|
||||||
|
// reasonToJump = `No frames decoded for ${time} ms.`;
|
||||||
|
hasReasonToJump = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.noDecodedFramesSince = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentTime === this.lastTime && this.currentTimeNotChangedSince === -1) {
|
||||||
|
this.currentTimeNotChangedSince = now;
|
||||||
|
} else {
|
||||||
|
this.currentTimeNotChangedSince = -1;
|
||||||
|
}
|
||||||
|
this.lastTime = currentTime;
|
||||||
|
if (this.tag.buffered.length) {
|
||||||
|
const end = this.tag.buffered.end(0);
|
||||||
|
const buffered = end - currentTime;
|
||||||
|
|
||||||
|
if ((end | 0) - currentTime > this.MAX_BUFFER) {
|
||||||
|
if (this.bigBufferSince === -1) {
|
||||||
|
this.bigBufferSince = now;
|
||||||
|
} else {
|
||||||
|
const time = now - this.bigBufferSince;
|
||||||
|
if (time > this.MAX_TIME_TO_RECOVER) {
|
||||||
|
// reasonToJump = `Buffer is bigger then ${this.MAX_BUFFER} (${buffered.toFixed(
|
||||||
|
// 3,
|
||||||
|
// )}) for ${time} ms.`;
|
||||||
|
hasReasonToJump = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.bigBufferSince = -1;
|
||||||
|
}
|
||||||
|
if (buffered < this.MAX_AHEAD) {
|
||||||
|
if (this.aheadOfBufferSince === -1) {
|
||||||
|
this.aheadOfBufferSince = now;
|
||||||
|
} else {
|
||||||
|
const time = now - this.aheadOfBufferSince;
|
||||||
|
if (time > this.MAX_TIME_TO_RECOVER) {
|
||||||
|
// reasonToJump = `Current time is ahead of end (${buffered}) for ${time} ms.`;
|
||||||
|
hasReasonToJump = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.aheadOfBufferSince = -1;
|
||||||
|
}
|
||||||
|
if (this.currentTimeNotChangedSince !== -1) {
|
||||||
|
const time = now - this.currentTimeNotChangedSince;
|
||||||
|
if (time > this.MAX_TIME_TO_RECOVER) {
|
||||||
|
// reasonToJump = `Current time not changed for ${time} ms.`;
|
||||||
|
hasReasonToJump = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasReasonToJump) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let waitingForSeekEnd = 0;
|
||||||
|
if (this.seekingSince !== -1) {
|
||||||
|
waitingForSeekEnd = now - this.seekingSince;
|
||||||
|
if (waitingForSeekEnd < 1500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// console.info(`${reasonToJump} Jumping to the end. ${waitingForSeekEnd}`);
|
||||||
|
|
||||||
|
const onSeekEnd = () => {
|
||||||
|
this.seekingSince = -1;
|
||||||
|
this.tag.removeEventListener('seeked', onSeekEnd);
|
||||||
|
this.tag.play();
|
||||||
|
};
|
||||||
|
if (this.seekingSince !== -1) {
|
||||||
|
console.warn(`[${this.name}]`, `Attempt to seek while already seeking! ${waitingForSeekEnd}`);
|
||||||
|
}
|
||||||
|
this.seekingSince = now;
|
||||||
|
this.tag.addEventListener('seeked', onSeekEnd);
|
||||||
|
this.tag.currentTime = this.tag.buffered.end(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected checkForIFrame(frame: Uint8Array): boolean {
|
||||||
|
if (!this.converter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.sourceBuffer = this.converter.sourceBuffer;
|
||||||
|
if (BasePlayer.isIFrame(frame)) {
|
||||||
|
let start = 0;
|
||||||
|
let end = 0;
|
||||||
|
if (this.tag.buffered && this.tag.buffered.length) {
|
||||||
|
start = this.tag.buffered.start(0);
|
||||||
|
end = this.tag.buffered.end(0);
|
||||||
|
}
|
||||||
|
if (end !== 0 && start < end) {
|
||||||
|
const block: Block = {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
};
|
||||||
|
this.blocks.push(block);
|
||||||
|
if (this.blocks.length > 10) {
|
||||||
|
this.waitUntilSegmentRemoved = true;
|
||||||
|
|
||||||
|
this.sourceBuffer.addEventListener('updateend', this.cleanSourceBuffer);
|
||||||
|
this.converter.appendRawData(frame);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.sourceBuffer) {
|
||||||
|
this.sourceBuffer.onupdateend = this.checkVideoResize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.waitUntilSegmentRemoved) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.converter.appendRawData(frame);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopConverter(): void {
|
||||||
|
if (this.converter) {
|
||||||
|
this.converter.appendRawData(new Uint8Array([]));
|
||||||
|
this.converter.pause();
|
||||||
|
delete this.converter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getFitToScreenStatus(): boolean {
|
||||||
|
return MsePlayer.getFitToScreenStatus(this.udid, this.displayInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadVideoSettings(): VideoSettings {
|
||||||
|
return MsePlayer.loadVideoSettings(this.udid, this.displayInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/player/MsePlayerForQVHack.ts
Normal file
37
src/app/player/MsePlayerForQVHack.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { MsePlayer } from './MsePlayer';
|
||||||
|
import Size from '../Size';
|
||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import { DisplayInfo } from '../DisplayInfo';
|
||||||
|
|
||||||
|
export class MsePlayerForQVHack extends MsePlayer {
|
||||||
|
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
|
||||||
|
lockedVideoOrientation: -1,
|
||||||
|
bitrate: 8000000,
|
||||||
|
maxFps: 30,
|
||||||
|
iFrameInterval: 10,
|
||||||
|
bounds: new Size(720, 720),
|
||||||
|
sendFrameMeta: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
public readonly resizeVideoToBounds: boolean = true;
|
||||||
|
constructor(
|
||||||
|
udid: string,
|
||||||
|
displayInfo?: DisplayInfo,
|
||||||
|
name = 'MSE_Player_For_QVHack',
|
||||||
|
tag = MsePlayerForQVHack.createElement(),
|
||||||
|
) {
|
||||||
|
super(udid, displayInfo, name, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected needScreenInfoBeforePlay(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPreferredVideoSetting(): VideoSettings {
|
||||||
|
return MsePlayerForQVHack.preferredVideoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setVideoSettings(): void {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/app/player/TinyH264Player.ts
Normal file
126
src/app/player/TinyH264Player.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { BaseCanvasBasedPlayer } from './BaseCanvasBasedPlayer';
|
||||||
|
import TinyH264Worker from 'worker-loader!./../../../vendor/tinyh264/H264NALDecoder.worker';
|
||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import YUVWebGLCanvas from '../../../vendor/tinyh264/YUVWebGLCanvas';
|
||||||
|
import YUVCanvas from '../../../vendor/tinyh264/YUVCanvas';
|
||||||
|
import Size from '../Size';
|
||||||
|
import { DisplayInfo } from '../DisplayInfo';
|
||||||
|
|
||||||
|
type WorkerMessage = {
|
||||||
|
type: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: ArrayBuffer;
|
||||||
|
renderStateId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TinyH264Player extends BaseCanvasBasedPlayer {
|
||||||
|
public static readonly storageKeyPrefix = 'Tinyh264Decoder';
|
||||||
|
public static readonly playerFullName = 'Tiny H264';
|
||||||
|
public static readonly playerCodeName = 'tinyh264';
|
||||||
|
private static videoStreamId = 1;
|
||||||
|
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
|
||||||
|
lockedVideoOrientation: -1,
|
||||||
|
bitrate: 524288,
|
||||||
|
maxFps: 24,
|
||||||
|
iFrameInterval: 5,
|
||||||
|
bounds: new Size(480, 480),
|
||||||
|
sendFrameMeta: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
private worker?: TinyH264Worker;
|
||||||
|
private isDecoderReady = false;
|
||||||
|
protected canvas?: YUVWebGLCanvas | YUVCanvas;
|
||||||
|
public readonly supportsScreenshot: boolean = true;
|
||||||
|
|
||||||
|
public static isSupported(): boolean {
|
||||||
|
return typeof WebAssembly === 'object' && typeof WebAssembly.instantiate === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(udid: string, displayInfo?: DisplayInfo, name = TinyH264Player.playerFullName) {
|
||||||
|
super(udid, displayInfo, name, TinyH264Player.storageKeyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onWorkerMessage = (event: MessageEvent): void => {
|
||||||
|
const message: WorkerMessage = event.data;
|
||||||
|
switch (message.type) {
|
||||||
|
case 'pictureReady':
|
||||||
|
const { width, height, data } = message;
|
||||||
|
this.onFrameDecoded(width, height, new Uint8Array(data));
|
||||||
|
break;
|
||||||
|
case 'decoderReady':
|
||||||
|
this.isDecoderReady = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error(`[${this.name}]`, Error(`Wrong message type "${message.type}"`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private initWorker(): void {
|
||||||
|
this.worker = new TinyH264Worker();
|
||||||
|
this.worker.addEventListener('message', this.onWorkerMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected initCanvas(width: number, height: number): void {
|
||||||
|
super.initCanvas(width, height);
|
||||||
|
|
||||||
|
if (BaseCanvasBasedPlayer.hasWebGLSupport()) {
|
||||||
|
this.canvas = new YUVWebGLCanvas(this.tag);
|
||||||
|
} else {
|
||||||
|
this.canvas = new YUVCanvas(this.tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected decode(data: Uint8Array): void {
|
||||||
|
if (!this.worker || !this.isDecoderReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.worker.postMessage(
|
||||||
|
{
|
||||||
|
type: 'decode',
|
||||||
|
data: data.buffer,
|
||||||
|
offset: data.byteOffset,
|
||||||
|
length: data.byteLength,
|
||||||
|
renderStateId: TinyH264Player.videoStreamId,
|
||||||
|
},
|
||||||
|
[data.buffer],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public play(): void {
|
||||||
|
super.play();
|
||||||
|
if (!this.worker) {
|
||||||
|
this.initWorker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
super.stop();
|
||||||
|
if (this.worker) {
|
||||||
|
this.worker.removeEventListener('message', this.onWorkerMessage);
|
||||||
|
this.worker.postMessage({ type: 'release', renderStateId: TinyH264Player.videoStreamId });
|
||||||
|
delete this.worker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPreferredVideoSetting(): VideoSettings {
|
||||||
|
return TinyH264Player.preferredVideoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clearState(): void {
|
||||||
|
super.clearState();
|
||||||
|
if (this.worker) {
|
||||||
|
this.worker.postMessage({ type: 'release', renderStateId: TinyH264Player.videoStreamId });
|
||||||
|
TinyH264Player.videoStreamId++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getFitToScreenStatus(): boolean {
|
||||||
|
return TinyH264Player.getFitToScreenStatus(this.udid, this.displayInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadVideoSettings(): VideoSettings {
|
||||||
|
return TinyH264Player.loadVideoSettings(this.udid, this.displayInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/app/player/WebCodecsPlayer.ts
Normal file
223
src/app/player/WebCodecsPlayer.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { BaseCanvasBasedPlayer } from './BaseCanvasBasedPlayer';
|
||||||
|
import VideoSettings from '../VideoSettings';
|
||||||
|
import Size from '../Size';
|
||||||
|
import { DisplayInfo } from '../DisplayInfo';
|
||||||
|
import H264Parser from 'h264-converter/dist/h264-parser';
|
||||||
|
import NALU from 'h264-converter/dist/util/NALU';
|
||||||
|
import ScreenInfo from '../ScreenInfo';
|
||||||
|
import Rect from '../Rect';
|
||||||
|
|
||||||
|
type ParametersSubSet = {
|
||||||
|
codec: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toHex(value: number) {
|
||||||
|
return value.toString(16).padStart(2, '0').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebCodecsPlayer extends BaseCanvasBasedPlayer {
|
||||||
|
public static readonly storageKeyPrefix = 'WebCodecsPlayer';
|
||||||
|
public static readonly playerFullName = 'WebCodecs';
|
||||||
|
public static readonly playerCodeName = 'webcodecs';
|
||||||
|
|
||||||
|
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
|
||||||
|
lockedVideoOrientation: -1,
|
||||||
|
bitrate: 524288,
|
||||||
|
maxFps: 24,
|
||||||
|
iFrameInterval: 5,
|
||||||
|
bounds: new Size(480, 480),
|
||||||
|
sendFrameMeta: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
public static isSupported(): boolean {
|
||||||
|
if (typeof VideoDecoder !== 'function' || typeof VideoDecoder.isConfigSupported !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: verify support
|
||||||
|
// const result = await VideoDecoder.isConfigSupported();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseSPS(data: Uint8Array): ParametersSubSet {
|
||||||
|
const {
|
||||||
|
profile_idc,
|
||||||
|
constraint_set_flags,
|
||||||
|
level_idc,
|
||||||
|
pic_width_in_mbs_minus1,
|
||||||
|
frame_crop_left_offset,
|
||||||
|
frame_crop_right_offset,
|
||||||
|
frame_mbs_only_flag,
|
||||||
|
pic_height_in_map_units_minus1,
|
||||||
|
frame_crop_top_offset,
|
||||||
|
frame_crop_bottom_offset,
|
||||||
|
sar,
|
||||||
|
} = H264Parser.parseSPS(data);
|
||||||
|
|
||||||
|
const sarScale = sar[0] / sar[1];
|
||||||
|
const codec = `avc1.${[profile_idc, constraint_set_flags, level_idc].map(toHex).join('')}`;
|
||||||
|
const width = Math.ceil(
|
||||||
|
((pic_width_in_mbs_minus1 + 1) * 16 - frame_crop_left_offset * 2 - frame_crop_right_offset * 2) * sarScale,
|
||||||
|
);
|
||||||
|
const height =
|
||||||
|
(2 - frame_mbs_only_flag) * (pic_height_in_map_units_minus1 + 1) * 16 -
|
||||||
|
(frame_mbs_only_flag ? 2 : 4) * (frame_crop_top_offset + frame_crop_bottom_offset);
|
||||||
|
return { codec, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly supportsScreenshot = true;
|
||||||
|
private context: CanvasRenderingContext2D;
|
||||||
|
private decoder: VideoDecoder;
|
||||||
|
private buffer: ArrayBuffer | undefined;
|
||||||
|
private hadIDR = false;
|
||||||
|
private bufferedSPS = false;
|
||||||
|
private bufferedPPS = false;
|
||||||
|
|
||||||
|
constructor(udid: string, displayInfo?: DisplayInfo, name = WebCodecsPlayer.playerFullName) {
|
||||||
|
super(udid, displayInfo, name, WebCodecsPlayer.storageKeyPrefix);
|
||||||
|
const context = this.tag.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw Error('Failed to get 2d context from canvas');
|
||||||
|
}
|
||||||
|
this.context = context;
|
||||||
|
this.decoder = this.createDecoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDecoder(): VideoDecoder {
|
||||||
|
return new VideoDecoder({
|
||||||
|
output: (frame) => {
|
||||||
|
this.onFrameDecoded(0, 0, frame);
|
||||||
|
},
|
||||||
|
error: (error: DOMException) => {
|
||||||
|
console.error(error, `code: ${error.code}`);
|
||||||
|
this.stop();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addToBuffer(data: Uint8Array): Uint8Array {
|
||||||
|
let array: Uint8Array;
|
||||||
|
if (this.buffer) {
|
||||||
|
array = new Uint8Array(this.buffer.byteLength + data.byteLength);
|
||||||
|
array.set(new Uint8Array(this.buffer));
|
||||||
|
array.set(new Uint8Array(data), this.buffer.byteLength);
|
||||||
|
} else {
|
||||||
|
array = data;
|
||||||
|
}
|
||||||
|
this.buffer = array.buffer;
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected scaleCanvas(width: number, height: number): void {
|
||||||
|
const videoSize = new Size(width, height);
|
||||||
|
let scale = 1;
|
||||||
|
if (this.bounds && !this.bounds.intersect(videoSize).equals(videoSize)) {
|
||||||
|
scale = Math.min(this.bounds.w / width, this.bounds.h / height);
|
||||||
|
}
|
||||||
|
const w = width * scale;
|
||||||
|
const h = height * scale;
|
||||||
|
const screenInfo = new ScreenInfo(new Rect(0, 0, width, height), new Size(w, h), 0);
|
||||||
|
this.emit('input-video-resize', screenInfo);
|
||||||
|
this.setScreenInfo(screenInfo);
|
||||||
|
|
||||||
|
// FIXME: canvas was initialized from `.setScreenInfo()` call above, but with wrong values
|
||||||
|
this.initCanvas(width, height);
|
||||||
|
if (scale !== 1) {
|
||||||
|
this.tag.style.transform = `scale(${scale.toFixed(4)})`;
|
||||||
|
} else {
|
||||||
|
this.tag.style.transform = ``;
|
||||||
|
}
|
||||||
|
this.tag.style.transformOrigin = 'top left';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected decode(data: Uint8Array): void {
|
||||||
|
if (!data || data.length < 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = data[4] & 31;
|
||||||
|
const isIDR = type === NALU.IDR;
|
||||||
|
|
||||||
|
if (type === NALU.SPS) {
|
||||||
|
const { codec, width, height } = WebCodecsPlayer.parseSPS(data.subarray(4));
|
||||||
|
this.scaleCanvas(width, height);
|
||||||
|
const config: VideoDecoderConfig = {
|
||||||
|
codec,
|
||||||
|
optimizeForLatency: true,
|
||||||
|
} as VideoDecoderConfig;
|
||||||
|
this.decoder.configure(config);
|
||||||
|
this.bufferedSPS = true;
|
||||||
|
this.addToBuffer(data);
|
||||||
|
this.hadIDR = false;
|
||||||
|
return;
|
||||||
|
} else if (type === NALU.PPS) {
|
||||||
|
this.bufferedPPS = true;
|
||||||
|
this.addToBuffer(data);
|
||||||
|
return;
|
||||||
|
} else if (type === NALU.SEI) {
|
||||||
|
// Workaround for lonely SEI from ws-qvh
|
||||||
|
if (!this.bufferedSPS || !this.bufferedPPS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const array = this.addToBuffer(data);
|
||||||
|
this.hadIDR = this.hadIDR || isIDR;
|
||||||
|
if (array && this.decoder.state === 'configured' && this.hadIDR) {
|
||||||
|
this.buffer = undefined;
|
||||||
|
this.bufferedPPS = false;
|
||||||
|
this.bufferedSPS = false;
|
||||||
|
this.decoder.decode(
|
||||||
|
new EncodedVideoChunk({
|
||||||
|
type: 'key',
|
||||||
|
timestamp: 0,
|
||||||
|
data: array.buffer,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected drawDecoded = (): void => {
|
||||||
|
if (this.receivedFirstFrame) {
|
||||||
|
const data = this.decodedFrames.shift();
|
||||||
|
if (data) {
|
||||||
|
const frame: VideoFrame = data.frame;
|
||||||
|
this.context.drawImage(frame, 0, 0);
|
||||||
|
frame.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.decodedFrames.length) {
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.drawDecoded);
|
||||||
|
} else {
|
||||||
|
this.animationFrameId = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected dropFrame(frame: VideoFrame): void {
|
||||||
|
frame.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getFitToScreenStatus(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPreferredVideoSetting(): VideoSettings {
|
||||||
|
return WebCodecsPlayer.preferredVideoSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadVideoSettings(): VideoSettings {
|
||||||
|
return WebCodecsPlayer.loadVideoSettings(this.udid, this.displayInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected needScreenInfoBeforePlay(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
super.stop();
|
||||||
|
if (this.decoder.state === 'configured') {
|
||||||
|
this.decoder.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/toolbox/ToolBox.ts
Normal file
19
src/app/toolbox/ToolBox.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ToolBoxElement } from './ToolBoxElement';
|
||||||
|
|
||||||
|
export class ToolBox {
|
||||||
|
private readonly holder: HTMLElement;
|
||||||
|
|
||||||
|
constructor(list: ToolBoxElement<any>[]) {
|
||||||
|
this.holder = document.createElement('div');
|
||||||
|
this.holder.classList.add('control-buttons-list', 'control-wrapper');
|
||||||
|
list.forEach((item) => {
|
||||||
|
item.getAllElements().forEach((el) => {
|
||||||
|
this.holder.appendChild(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHolderElement(): HTMLElement {
|
||||||
|
return this.holder;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/toolbox/ToolBoxButton.ts
Normal file
21
src/app/toolbox/ToolBoxButton.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Optional, ToolBoxElement } from './ToolBoxElement';
|
||||||
|
import SvgImage, { Icon } from '../ui/SvgImage';
|
||||||
|
|
||||||
|
export class ToolBoxButton extends ToolBoxElement<HTMLButtonElement> {
|
||||||
|
private readonly btn: HTMLButtonElement;
|
||||||
|
constructor(title: string, icon: Icon, optional?: Optional) {
|
||||||
|
super(title, optional);
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.classList.add('control-button');
|
||||||
|
btn.title = title;
|
||||||
|
btn.appendChild(SvgImage.create(icon));
|
||||||
|
this.btn = btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getElement(): HTMLButtonElement {
|
||||||
|
return this.btn;
|
||||||
|
}
|
||||||
|
public getAllElements(): HTMLElement[] {
|
||||||
|
return [this.btn];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/app/toolbox/ToolBoxCheckbox.ts
Normal file
51
src/app/toolbox/ToolBoxCheckbox.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Optional, ToolBoxElement } from './ToolBoxElement';
|
||||||
|
import SvgImage, { Icon } from '../ui/SvgImage';
|
||||||
|
|
||||||
|
type Icons = {
|
||||||
|
on?: Icon;
|
||||||
|
off: Icon;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ToolBoxCheckbox extends ToolBoxElement<HTMLInputElement> {
|
||||||
|
private readonly input: HTMLInputElement;
|
||||||
|
private readonly label: HTMLLabelElement;
|
||||||
|
private readonly imageOn?: Element;
|
||||||
|
private readonly imageOff: Element;
|
||||||
|
constructor(title: string, icons: Icons | Icon, opt_id?: string, optional?: Optional) {
|
||||||
|
super(title, optional);
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.title = title;
|
||||||
|
label.classList.add('control-button');
|
||||||
|
let iconOff: Icon;
|
||||||
|
let iconOn: Icon | undefined;
|
||||||
|
if (typeof icons !== 'number') {
|
||||||
|
iconOff = icons.off;
|
||||||
|
iconOn = icons.on;
|
||||||
|
} else {
|
||||||
|
iconOff = icons;
|
||||||
|
}
|
||||||
|
this.imageOff = SvgImage.create(iconOff);
|
||||||
|
this.imageOff.classList.add('image', 'image-off');
|
||||||
|
label.appendChild(this.imageOff);
|
||||||
|
if (iconOn) {
|
||||||
|
this.imageOn = SvgImage.create(iconOn);
|
||||||
|
this.imageOn.classList.add('image', 'image-on');
|
||||||
|
label.appendChild(this.imageOn);
|
||||||
|
input.classList.add('two-images');
|
||||||
|
}
|
||||||
|
const id = opt_id || title.toLowerCase().replace(' ', '_');
|
||||||
|
label.htmlFor = input.id = `input_${id}`;
|
||||||
|
this.input = input;
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getElement(): HTMLInputElement {
|
||||||
|
return this.input;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllElements(): HTMLElement[] {
|
||||||
|
return [this.input, this.label];
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/toolbox/ToolBoxElement.ts
Normal file
53
src/app/toolbox/ToolBoxElement.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export type Optional = {
|
||||||
|
[index: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// type Listener = <K extends keyof HTMLElementEventMap, T extends HTMLElement>(type: K, el: ToolBoxElement<T>) => any;
|
||||||
|
|
||||||
|
export abstract class ToolBoxElement<T extends HTMLElement> {
|
||||||
|
private listeners: Map<string, Set<<K extends keyof HTMLElementEventMap>(type: K, el: ToolBoxElement<T>) => any>> =
|
||||||
|
new Map();
|
||||||
|
protected constructor(public readonly title: string, public readonly optional?: Optional) {}
|
||||||
|
|
||||||
|
public abstract getElement(): T;
|
||||||
|
public abstract getAllElements(): HTMLElement[];
|
||||||
|
|
||||||
|
public addEventListener<K extends keyof HTMLElementEventMap>(
|
||||||
|
type: K,
|
||||||
|
listener: <K extends keyof HTMLElementEventMap>(type: K, el: ToolBoxElement<T>) => any,
|
||||||
|
options?: boolean | AddEventListenerOptions,
|
||||||
|
): void {
|
||||||
|
const set = this.listeners.get(type) || new Set();
|
||||||
|
if (!set.size) {
|
||||||
|
const element = this.getElement();
|
||||||
|
element.addEventListener(type, this.onEvent, options);
|
||||||
|
}
|
||||||
|
set.add(listener);
|
||||||
|
this.listeners.set(type, set);
|
||||||
|
}
|
||||||
|
public removeEventListener<K extends keyof HTMLElementEventMap>(
|
||||||
|
type: K,
|
||||||
|
listener: <K extends keyof HTMLElementEventMap>(type: K, el: ToolBoxElement<T>) => any,
|
||||||
|
): void {
|
||||||
|
const set = this.listeners.get(type);
|
||||||
|
if (!set) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set.delete(listener);
|
||||||
|
if (!set.size) {
|
||||||
|
this.listeners.delete(type);
|
||||||
|
const element = this.getElement();
|
||||||
|
element.removeEventListener(type, this.onEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onEvent = <K extends keyof HTMLElementEventMap>(ev: HTMLElementEventMap[K]): void => {
|
||||||
|
const set = this.listeners.get(ev.type);
|
||||||
|
if (!set) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = ev.type as K;
|
||||||
|
set.forEach((listener) => {
|
||||||
|
listener(type, this);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
23
src/app/ui/HtmlTag.ts
Normal file
23
src/app/ui/HtmlTag.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type Value = any;
|
||||||
|
|
||||||
|
function htmlValue(value: Value): string {
|
||||||
|
if (value instanceof HTMLTemplateElement) {
|
||||||
|
return value.innerHTML;
|
||||||
|
}
|
||||||
|
if (typeof value === 'undefined') {
|
||||||
|
return 'undefined';
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
const e = document.createElement('dummy');
|
||||||
|
e.innerText = value.toString();
|
||||||
|
return e.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const html = function html(strings: TemplateStringsArray, ...values: ReadonlyArray<Value>): HTMLTemplateElement {
|
||||||
|
const template = document.createElement('template') as HTMLTemplateElement;
|
||||||
|
template.innerHTML = values.reduce((acc, v, idx) => acc + htmlValue(v) + strings[idx + 1], strings[0]).toString();
|
||||||
|
return template;
|
||||||
|
};
|
||||||
91
src/app/ui/SvgImage.ts
Normal file
91
src/app/ui/SvgImage.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import KeyboardSVG from '../../public/images/skin-light/ic_keyboard_678_48dp.svg';
|
||||||
|
import MoreSVG from '../../public/images/skin-light/ic_more_horiz_678_48dp.svg';
|
||||||
|
import CameraSVG from '../../public/images/skin-light/ic_photo_camera_678_48dp.svg';
|
||||||
|
import PowerSVG from '../../public/images/skin-light/ic_power_settings_new_678_48px.svg';
|
||||||
|
import VolumeDownSVG from '../../public/images/skin-light/ic_volume_down_678_48px.svg';
|
||||||
|
import VolumeUpSVG from '../../public/images/skin-light/ic_volume_up_678_48px.svg';
|
||||||
|
import BackSVG from '../../public/images/skin-light/System_Back_678.svg';
|
||||||
|
import HomeSVG from '../../public/images/skin-light/System_Home_678.svg';
|
||||||
|
import OverviewSVG from '../../public/images/skin-light/System_Overview_678.svg';
|
||||||
|
import CancelSVG from '../../public/images/buttons/cancel.svg';
|
||||||
|
import OfflineSVG from '../../public/images/buttons/offline.svg';
|
||||||
|
import RefreshSVG from '../../public/images/buttons/refresh.svg';
|
||||||
|
import SettingsSVG from '../../public/images/buttons/settings.svg';
|
||||||
|
import MenuSVG from '../../public/images/buttons/menu.svg';
|
||||||
|
import ArrowBackSVG from '../../public/images/buttons/arrow_back.svg';
|
||||||
|
import ToggleOnSVG from '../../public/images/buttons/toggle_on.svg';
|
||||||
|
import ToggleOffSVG from '../../public/images/buttons/toggle_off.svg';
|
||||||
|
|
||||||
|
export enum Icon {
|
||||||
|
BACK,
|
||||||
|
HOME,
|
||||||
|
OVERVIEW,
|
||||||
|
POWER,
|
||||||
|
VOLUME_UP,
|
||||||
|
VOLUME_DOWN,
|
||||||
|
MORE,
|
||||||
|
CAMERA,
|
||||||
|
KEYBOARD,
|
||||||
|
CANCEL,
|
||||||
|
OFFLINE,
|
||||||
|
REFRESH,
|
||||||
|
SETTINGS,
|
||||||
|
MENU,
|
||||||
|
ARROW_BACK,
|
||||||
|
TOGGLE_ON,
|
||||||
|
TOGGLE_OFF,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SvgImage {
|
||||||
|
static Icon = Icon;
|
||||||
|
private static getSvgString(type: Icon): string {
|
||||||
|
switch (type) {
|
||||||
|
case Icon.KEYBOARD:
|
||||||
|
return KeyboardSVG;
|
||||||
|
case Icon.MORE:
|
||||||
|
return MoreSVG;
|
||||||
|
case Icon.CAMERA:
|
||||||
|
return CameraSVG;
|
||||||
|
case Icon.POWER:
|
||||||
|
return PowerSVG;
|
||||||
|
case Icon.VOLUME_DOWN:
|
||||||
|
return VolumeDownSVG;
|
||||||
|
case Icon.VOLUME_UP:
|
||||||
|
return VolumeUpSVG;
|
||||||
|
case Icon.BACK:
|
||||||
|
return BackSVG;
|
||||||
|
case Icon.HOME:
|
||||||
|
return HomeSVG;
|
||||||
|
case Icon.OVERVIEW:
|
||||||
|
return OverviewSVG;
|
||||||
|
case Icon.CANCEL:
|
||||||
|
return CancelSVG;
|
||||||
|
case Icon.OFFLINE:
|
||||||
|
return OfflineSVG;
|
||||||
|
case Icon.REFRESH:
|
||||||
|
return RefreshSVG;
|
||||||
|
case Icon.SETTINGS:
|
||||||
|
return SettingsSVG;
|
||||||
|
case Icon.MENU:
|
||||||
|
return MenuSVG;
|
||||||
|
case Icon.ARROW_BACK:
|
||||||
|
return ArrowBackSVG;
|
||||||
|
case Icon.TOGGLE_ON:
|
||||||
|
return ToggleOnSVG;
|
||||||
|
case Icon.TOGGLE_OFF:
|
||||||
|
return ToggleOffSVG;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static create(type: Icon): Element {
|
||||||
|
const dummy = document.createElement('div');
|
||||||
|
dummy.innerHTML = this.getSvgString(type);
|
||||||
|
const svg = dummy.children[0];
|
||||||
|
const titles = svg.getElementsByTagName('title');
|
||||||
|
for (let i = 0, l = titles.length; i < l; i++) {
|
||||||
|
svg.removeChild(titles[i]);
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/common/Action.ts
Normal file
15
src/common/Action.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export enum ACTION {
|
||||||
|
LIST_HOSTS = 'list-hosts',
|
||||||
|
APPL_DEVICE_LIST = 'appl-device-list',
|
||||||
|
GOOG_DEVICE_LIST = 'goog-device-list',
|
||||||
|
MULTIPLEX = 'multiplex',
|
||||||
|
SHELL = 'shell',
|
||||||
|
PROXY_WS = 'proxy-ws',
|
||||||
|
PROXY_ADB = 'proxy-adb',
|
||||||
|
DEVTOOLS = 'devtools',
|
||||||
|
STREAM_SCRCPY = 'stream',
|
||||||
|
STREAM_WS_QVH = 'stream-qvh',
|
||||||
|
STREAM_MJPEG = 'stream-mjpeg',
|
||||||
|
PROXY_WDA = 'proxy-wda',
|
||||||
|
FILE_LISTING = 'list-files',
|
||||||
|
}
|
||||||
9
src/common/ChannelCode.ts
Normal file
9
src/common/ChannelCode.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export enum ChannelCode {
|
||||||
|
FSLS = 'FSLS', // File System LiSt
|
||||||
|
HSTS = 'HSTS', // HoSTS List
|
||||||
|
SHEL = 'SHEL', // SHELl
|
||||||
|
GTRC = 'GTRC', // Goog device TRaCer
|
||||||
|
ATRC = 'ATRC', // Appl device TRaCer
|
||||||
|
WDAP = 'WDAP', // WebDriverAgent Proxy
|
||||||
|
QVHS = 'QVHS', // Quicktime_Video_Hack Stream
|
||||||
|
}
|
||||||
20
src/common/Constants.ts
Normal file
20
src/common/Constants.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export const SERVER_PACKAGE = 'com.genymobile.scrcpy.Server';
|
||||||
|
export const SERVER_PORT = 8886;
|
||||||
|
export const SERVER_VERSION = '1.19-ws6';
|
||||||
|
|
||||||
|
export const SERVER_TYPE = 'web';
|
||||||
|
|
||||||
|
export const LOG_LEVEL = 'ERROR';
|
||||||
|
|
||||||
|
let SCRCPY_LISTENS_ON_ALL_INTERFACES;
|
||||||
|
/// #if SCRCPY_LISTENS_ON_ALL_INTERFACES
|
||||||
|
SCRCPY_LISTENS_ON_ALL_INTERFACES = true;
|
||||||
|
/// #else
|
||||||
|
SCRCPY_LISTENS_ON_ALL_INTERFACES = false;
|
||||||
|
/// #endif
|
||||||
|
|
||||||
|
const ARGUMENTS = [SERVER_VERSION, SERVER_TYPE, LOG_LEVEL, SERVER_PORT, SCRCPY_LISTENS_ON_ALL_INTERFACES];
|
||||||
|
|
||||||
|
export const SERVER_PROCESS_NAME = 'app_process';
|
||||||
|
|
||||||
|
export const ARGS_STRING = `/ ${SERVER_PACKAGE} ${ARGUMENTS.join(' ')} 2>&1 > /dev/null`;
|
||||||
77
src/common/ControlCenterCommand.ts
Normal file
77
src/common/ControlCenterCommand.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { WDAMethod } from './WDAMethod';
|
||||||
|
|
||||||
|
export class ControlCenterCommand {
|
||||||
|
public static KILL_SERVER = 'kill_server';
|
||||||
|
public static START_SERVER = 'start_server';
|
||||||
|
public static UPDATE_INTERFACES = 'update_interfaces';
|
||||||
|
public static CONFIGURE_STREAM = 'configure_stream';
|
||||||
|
public static RUN_WDA = 'run-wda';
|
||||||
|
public static REQUEST_WDA = 'request-wda';
|
||||||
|
|
||||||
|
private id = -1;
|
||||||
|
private type = '';
|
||||||
|
private pid = 0;
|
||||||
|
private udid = '';
|
||||||
|
private method = '';
|
||||||
|
private args?: any;
|
||||||
|
private data?: any;
|
||||||
|
|
||||||
|
public static fromJSON(json: string): ControlCenterCommand {
|
||||||
|
const body = JSON.parse(json);
|
||||||
|
if (!body) {
|
||||||
|
throw new Error('Invalid input');
|
||||||
|
}
|
||||||
|
const command = new ControlCenterCommand();
|
||||||
|
const data = (command.data = body.data);
|
||||||
|
command.id = body.id;
|
||||||
|
command.type = body.type;
|
||||||
|
|
||||||
|
if (typeof data.udid === 'string') {
|
||||||
|
command.udid = data.udid;
|
||||||
|
}
|
||||||
|
switch (body.type) {
|
||||||
|
case this.KILL_SERVER:
|
||||||
|
if (typeof data.pid !== 'number' && data.pid <= 0) {
|
||||||
|
throw new Error('Invalid "pid" value');
|
||||||
|
}
|
||||||
|
command.pid = data.pid;
|
||||||
|
return command;
|
||||||
|
case this.REQUEST_WDA:
|
||||||
|
if (typeof data.method !== 'string') {
|
||||||
|
throw new Error('Invalid "method" value');
|
||||||
|
}
|
||||||
|
command.method = data.method;
|
||||||
|
command.args = data.args;
|
||||||
|
return command;
|
||||||
|
case this.START_SERVER:
|
||||||
|
case this.UPDATE_INTERFACES:
|
||||||
|
case this.CONFIGURE_STREAM:
|
||||||
|
case this.RUN_WDA:
|
||||||
|
return command;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown command "${body.command}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getType(): string {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
public getPid(): number {
|
||||||
|
return this.pid;
|
||||||
|
}
|
||||||
|
public getUdid(): string {
|
||||||
|
return this.udid;
|
||||||
|
}
|
||||||
|
public getId(): number {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
public getMethod(): WDAMethod | string {
|
||||||
|
return this.method;
|
||||||
|
}
|
||||||
|
public getData(): any {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
public getArgs(): any {
|
||||||
|
return this.args;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/common/DeviceState.ts
Normal file
6
src/common/DeviceState.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export enum DeviceState {
|
||||||
|
DEVICE = 'device',
|
||||||
|
DISCONNECTED = 'disconnected',
|
||||||
|
|
||||||
|
CONNECTED = 'Connected',
|
||||||
|
}
|
||||||
20
src/common/HostTrackerMessage.ts
Normal file
20
src/common/HostTrackerMessage.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Message } from '../types/Message';
|
||||||
|
import { HostItem } from '../types/Configuration';
|
||||||
|
|
||||||
|
export enum MessageType {
|
||||||
|
HOSTS = 'hosts',
|
||||||
|
ERROR = 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageHosts extends Message {
|
||||||
|
type: 'hosts';
|
||||||
|
data: {
|
||||||
|
local?: { type: string }[];
|
||||||
|
remote?: HostItem[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageError extends Message {
|
||||||
|
type: 'error';
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
147
src/common/ProductType.ts
Normal file
147
src/common/ProductType.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
export class ProductType {
|
||||||
|
// from https://gist.github.com/adamawolf/3048717
|
||||||
|
private static type: Record<string, string> = {
|
||||||
|
i386: 'iPhone Simulator',
|
||||||
|
x86_64: 'iPhone Simulator',
|
||||||
|
arm64: 'iPhone Simulator',
|
||||||
|
'iPhone1,1': 'iPhone',
|
||||||
|
'iPhone1,2': 'iPhone 3G',
|
||||||
|
'iPhone2,1': 'iPhone 3GS',
|
||||||
|
'iPhone3,1': 'iPhone 4',
|
||||||
|
'iPhone3,2': 'iPhone 4 GSM Rev A',
|
||||||
|
'iPhone3,3': 'iPhone 4 CDMA',
|
||||||
|
'iPhone4,1': 'iPhone 4S',
|
||||||
|
'iPhone5,1': 'iPhone 5 (GSM)',
|
||||||
|
'iPhone5,2': 'iPhone 5 (GSM+CDMA)',
|
||||||
|
'iPhone5,3': 'iPhone 5C (GSM)',
|
||||||
|
'iPhone5,4': 'iPhone 5C (Global)',
|
||||||
|
'iPhone6,1': 'iPhone 5S (GSM)',
|
||||||
|
'iPhone6,2': 'iPhone 5S (Global)',
|
||||||
|
'iPhone7,1': 'iPhone 6 Plus',
|
||||||
|
'iPhone7,2': 'iPhone 6',
|
||||||
|
'iPhone8,1': 'iPhone 6s',
|
||||||
|
'iPhone8,2': 'iPhone 6s Plus',
|
||||||
|
'iPhone8,4': 'iPhone SE (GSM)',
|
||||||
|
'iPhone9,1': 'iPhone 7',
|
||||||
|
'iPhone9,2': 'iPhone 7 Plus',
|
||||||
|
'iPhone9,3': 'iPhone 7',
|
||||||
|
'iPhone9,4': 'iPhone 7 Plus',
|
||||||
|
'iPhone10,1': 'iPhone 8',
|
||||||
|
'iPhone10,2': 'iPhone 8 Plus',
|
||||||
|
'iPhone10,3': 'iPhone X Global',
|
||||||
|
'iPhone10,4': 'iPhone 8',
|
||||||
|
'iPhone10,5': 'iPhone 8 Plus',
|
||||||
|
'iPhone10,6': 'iPhone X GSM',
|
||||||
|
'iPhone11,2': 'iPhone XS',
|
||||||
|
'iPhone11,4': 'iPhone XS Max',
|
||||||
|
'iPhone11,6': 'iPhone XS Max Global',
|
||||||
|
'iPhone11,8': 'iPhone XR',
|
||||||
|
'iPhone12,1': 'iPhone 11',
|
||||||
|
'iPhone12,3': 'iPhone 11 Pro',
|
||||||
|
'iPhone12,5': 'iPhone 11 Pro Max',
|
||||||
|
'iPhone12,8': 'iPhone SE 2nd Gen',
|
||||||
|
'iPhone13,1': 'iPhone 12 Mini',
|
||||||
|
'iPhone13,2': 'iPhone 12',
|
||||||
|
'iPhone13,3': 'iPhone 12 Pro',
|
||||||
|
'iPhone13,4': 'iPhone 12 Pro Max',
|
||||||
|
'iPod1,1': '1st Gen iPod',
|
||||||
|
'iPod2,1': '2nd Gen iPod',
|
||||||
|
'iPod3,1': '3rd Gen iPod',
|
||||||
|
'iPod4,1': '4th Gen iPod',
|
||||||
|
'iPod5,1': '5th Gen iPod',
|
||||||
|
'iPod7,1': '6th Gen iPod',
|
||||||
|
'iPod9,1': '7th Gen iPod',
|
||||||
|
'iPad1,1': 'iPad',
|
||||||
|
'iPad1,2': 'iPad 3G',
|
||||||
|
'iPad2,1': '2nd Gen iPad',
|
||||||
|
'iPad2,2': '2nd Gen iPad GSM',
|
||||||
|
'iPad2,3': '2nd Gen iPad CDMA',
|
||||||
|
'iPad2,4': '2nd Gen iPad New Revision',
|
||||||
|
'iPad3,1': '3rd Gen iPad',
|
||||||
|
'iPad3,2': '3rd Gen iPad CDMA',
|
||||||
|
'iPad3,3': '3rd Gen iPad GSM',
|
||||||
|
'iPad2,5': 'iPad mini',
|
||||||
|
'iPad2,6': 'iPad mini GSM+LTE',
|
||||||
|
'iPad2,7': 'iPad mini CDMA+LTE',
|
||||||
|
'iPad3,4': '4th Gen iPad',
|
||||||
|
'iPad3,5': '4th Gen iPad GSM+LTE',
|
||||||
|
'iPad3,6': '4th Gen iPad CDMA+LTE',
|
||||||
|
'iPad4,1': 'iPad Air (WiFi)',
|
||||||
|
'iPad4,2': 'iPad Air (GSM+CDMA)',
|
||||||
|
'iPad4,3': '1st Gen iPad Air (China)',
|
||||||
|
'iPad4,4': 'iPad mini Retina (WiFi)',
|
||||||
|
'iPad4,5': 'iPad mini Retina (GSM+CDMA)',
|
||||||
|
'iPad4,6': 'iPad mini Retina (China)',
|
||||||
|
'iPad4,7': 'iPad mini 3 (WiFi)',
|
||||||
|
'iPad4,8': 'iPad mini 3 (GSM+CDMA)',
|
||||||
|
'iPad4,9': 'iPad Mini 3 (China)',
|
||||||
|
'iPad5,1': 'iPad mini 4 (WiFi)',
|
||||||
|
'iPad5,2': '4th Gen iPad mini (WiFi+Cellular)',
|
||||||
|
'iPad5,3': 'iPad Air 2 (WiFi)',
|
||||||
|
'iPad5,4': 'iPad Air 2 (Cellular)',
|
||||||
|
'iPad6,3': 'iPad Pro (9.7 inch, WiFi)',
|
||||||
|
'iPad6,4': 'iPad Pro (9.7 inch, WiFi+LTE)',
|
||||||
|
'iPad6,7': 'iPad Pro (12.9 inch, WiFi)',
|
||||||
|
'iPad6,8': 'iPad Pro (12.9 inch, WiFi+LTE)',
|
||||||
|
'iPad6,11': 'iPad (2017)',
|
||||||
|
'iPad6,12': 'iPad (2017)',
|
||||||
|
'iPad7,1': 'iPad Pro 2nd Gen (WiFi)',
|
||||||
|
'iPad7,2': 'iPad Pro 2nd Gen (WiFi+Cellular)',
|
||||||
|
'iPad7,3': 'iPad Pro 10.5-inch',
|
||||||
|
'iPad7,4': 'iPad Pro 10.5-inch',
|
||||||
|
'iPad7,5': 'iPad 6th Gen (WiFi)',
|
||||||
|
'iPad7,6': 'iPad 6th Gen (WiFi+Cellular)',
|
||||||
|
'iPad7,11': 'iPad 7th Gen 10.2-inch (WiFi)',
|
||||||
|
'iPad7,12': 'iPad 7th Gen 10.2-inch (WiFi+Cellular)',
|
||||||
|
'iPad8,1': 'iPad Pro 11 inch 3rd Gen (WiFi)',
|
||||||
|
'iPad8,2': 'iPad Pro 11 inch 3rd Gen (1TB, WiFi)',
|
||||||
|
'iPad8,3': 'iPad Pro 11 inch 3rd Gen (WiFi+Cellular)',
|
||||||
|
'iPad8,4': 'iPad Pro 11 inch 3rd Gen (1TB, WiFi+Cellular)',
|
||||||
|
'iPad8,5': 'iPad Pro 12.9 inch 3rd Gen (WiFi)',
|
||||||
|
'iPad8,6': 'iPad Pro 12.9 inch 3rd Gen (1TB, WiFi)',
|
||||||
|
'iPad8,7': 'iPad Pro 12.9 inch 3rd Gen (WiFi+Cellular)',
|
||||||
|
'iPad8,8': 'iPad Pro 12.9 inch 3rd Gen (1TB, WiFi+Cellular)',
|
||||||
|
'iPad8,9': 'iPad Pro 11 inch 4th Gen (WiFi)',
|
||||||
|
'iPad8,10': 'iPad Pro 11 inch 4th Gen (WiFi+Cellular)',
|
||||||
|
'iPad8,11': 'iPad Pro 12.9 inch 4th Gen (WiFi)',
|
||||||
|
'iPad8,12': 'iPad Pro 12.9 inch 4th Gen (WiFi+Cellular)',
|
||||||
|
'iPad11,1': 'iPad mini 5th Gen (WiFi)',
|
||||||
|
'iPad11,2': 'iPad mini 5th Gen',
|
||||||
|
'iPad11,3': 'iPad Air 3rd Gen (WiFi)',
|
||||||
|
'iPad11,4': 'iPad Air 3rd Gen',
|
||||||
|
'iPad11,6': 'iPad 8th Gen (WiFi)',
|
||||||
|
'iPad11,7': 'iPad 8th Gen (WiFi+Cellular)',
|
||||||
|
'iPad13,1': 'iPad air 4th Gen (WiFi)',
|
||||||
|
'iPad13,2': 'iPad air 4th Gen (WiFi+Cellular)',
|
||||||
|
'Watch1,1': 'Apple Watch 38mm case',
|
||||||
|
'Watch1,2': 'Apple Watch 42mm case',
|
||||||
|
'Watch2,6': 'Apple Watch Series 1 38mm case',
|
||||||
|
'Watch2,7': 'Apple Watch Series 1 42mm case',
|
||||||
|
'Watch2,3': 'Apple Watch Series 2 38mm case',
|
||||||
|
'Watch2,4': 'Apple Watch Series 2 42mm case',
|
||||||
|
'Watch3,1': 'Apple Watch Series 3 38mm case (GPS+Cellular)',
|
||||||
|
'Watch3,2': 'Apple Watch Series 3 42mm case (GPS+Cellular)',
|
||||||
|
'Watch3,3': 'Apple Watch Series 3 38mm case (GPS)',
|
||||||
|
'Watch3,4': 'Apple Watch Series 3 42mm case (GPS)',
|
||||||
|
'Watch4,1': 'Apple Watch Series 4 40mm case (GPS)',
|
||||||
|
'Watch4,2': 'Apple Watch Series 4 44mm case (GPS)',
|
||||||
|
'Watch4,3': 'Apple Watch Series 4 40mm case (GPS+Cellular)',
|
||||||
|
'Watch4,4': 'Apple Watch Series 4 44mm case (GPS+Cellular)',
|
||||||
|
'Watch5,1': 'Apple Watch Series 5 40mm case (GPS)',
|
||||||
|
'Watch5,2': 'Apple Watch Series 5 44mm case (GPS)',
|
||||||
|
'Watch5,3': 'Apple Watch Series 5 40mm case (GPS+Cellular)',
|
||||||
|
'Watch5,4': 'Apple Watch Series 5 44mm case (GPS+Cellular)',
|
||||||
|
'Watch5,9': 'Apple Watch SE 40mm case (GPS)',
|
||||||
|
'Watch5,10': 'Apple Watch SE 44mm case (GPS)',
|
||||||
|
'Watch5,11': 'Apple Watch SE 40mm case (GPS+Cellular)',
|
||||||
|
'Watch5,12': 'Apple Watch SE 44mm case (GPS+Cellular)',
|
||||||
|
'Watch6,1': 'Apple Watch Series 6 40mm case (GPS)',
|
||||||
|
'Watch6,2': 'Apple Watch Series 6 44mm case (GPS)',
|
||||||
|
'Watch6,3': 'Apple Watch Series 6 40mm case (GPS+Cellular)',
|
||||||
|
'Watch6,4': 'Apple Watch Series 6 44mm case (GPS+Cellular)',
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getModel(productType: string): string {
|
||||||
|
return this.type[productType] || productType;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/common/TypedEmitter.ts
Normal file
43
src/common/TypedEmitter.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type EventMap = Record<string, any>;
|
||||||
|
export type EventKey<T extends EventMap> = string & keyof T;
|
||||||
|
export type EventReceiver<T> = (params: T) => void;
|
||||||
|
|
||||||
|
interface Emitter<T extends EventMap> {
|
||||||
|
on<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void;
|
||||||
|
off<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void;
|
||||||
|
emit<K extends EventKey<T>>(eventName: K, params: T[K]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TypedEmitter<T extends EventMap> implements Emitter<T> {
|
||||||
|
private emitter = new EventEmitter();
|
||||||
|
addEventListener<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
|
||||||
|
this.emitter.on(eventName, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
|
||||||
|
this.emitter.off(eventName, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(event: Event): boolean {
|
||||||
|
return this.emitter.emit(event.type, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
on<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
|
||||||
|
this.emitter.on(eventName, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
once<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
|
||||||
|
this.emitter.once(eventName, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
off<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
|
||||||
|
this.emitter.off(eventName, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends EventKey<T>>(eventName: K, params: T[K]): boolean {
|
||||||
|
return this.emitter.emit(eventName, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/common/WDAMethod.ts
Normal file
8
src/common/WDAMethod.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export enum WDAMethod {
|
||||||
|
CLICK = 'CLICK',
|
||||||
|
SCROLL = 'SCROLL',
|
||||||
|
PRESS_BUTTON = 'PRESS_BUTTON',
|
||||||
|
GET_SCREEN_WIDTH = 'GET_SCREEN_WIDTH',
|
||||||
|
APPIUM_SETTINGS = 'APPIUM_SETTINGS',
|
||||||
|
SEND_KEYS = 'SEND_KEYS',
|
||||||
|
}
|
||||||
5
src/common/WdaStatus.ts
Normal file
5
src/common/WdaStatus.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum WdaStatus {
|
||||||
|
STARTING = 'STARTING',
|
||||||
|
STARTED = 'STARTED',
|
||||||
|
STOPPED = 'STOPPED',
|
||||||
|
}
|
||||||
15
src/packages/multiplexer/CloseEventClass.ts
Normal file
15
src/packages/multiplexer/CloseEventClass.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Event2 } from './Event';
|
||||||
|
|
||||||
|
export class CloseEvent2 extends Event2 implements CloseEvent {
|
||||||
|
readonly code: number;
|
||||||
|
readonly reason: string;
|
||||||
|
readonly wasClean: boolean;
|
||||||
|
constructor(type: string, { code, reason }: CloseEventInit = {}) {
|
||||||
|
super(type);
|
||||||
|
this.code = code || 0;
|
||||||
|
this.reason = reason || '';
|
||||||
|
this.wasClean = this.code === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloseEventClass = typeof CloseEvent !== 'undefined' ? CloseEvent : CloseEvent2;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user