111
This commit is contained in:
23
WebApp/client/.eslintrc.json
Normal file
23
WebApp/client/.eslintrc.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:jest/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"jest"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": "latest"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true ,
|
||||
"jest": true
|
||||
},
|
||||
"rules": {
|
||||
"semi": "error",
|
||||
"no-extra-semi": "error"
|
||||
}
|
||||
}
|
||||
195
WebApp/client/jest.config.js
Normal file
195
WebApp/client/jest.config.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
export default {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/private/var/folders/wt/swsbjj0x061bdb0y4dqc0g4c0000gn/T/jest_dx",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: "v8",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
moduleFileExtensions: [
|
||||
"js",
|
||||
"jsx",
|
||||
"ts",
|
||||
"tsx",
|
||||
"json",
|
||||
"node"
|
||||
],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
setupFilesAfterEnv: ['./jest.setup.js'],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "jest-environment-jsdom",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
testEnvironmentOptions: {
|
||||
url: "http://localhost:8081"
|
||||
},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
"**/__tests__/**/*.[jt]s?(x)",
|
||||
"**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// This option set timeout each test
|
||||
testTimeout: 5000,
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
35
WebApp/client/jest.setup.js
Normal file
35
WebApp/client/jest.setup.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/* eslint-disable no-undef */
|
||||
import fetch from 'node-fetch';
|
||||
import { TextEncoder, TextDecoder } from 'util';
|
||||
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/peerconnectionmock';
|
||||
import ResizeObserverMock from './test/resizeobservermock';
|
||||
|
||||
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
|
||||
|
||||
if (!window.fetch) {
|
||||
window.fetch = fetch;
|
||||
}
|
||||
|
||||
if (!window.TextEncoder) {
|
||||
window.TextEncoder = TextEncoder;
|
||||
}
|
||||
|
||||
if (!window.TextDecoder) {
|
||||
window.TextDecoder = TextDecoder;
|
||||
}
|
||||
|
||||
if (!window.RTCPeerConnection) {
|
||||
window.RTCPeerConnection = PeerConnectionMock;
|
||||
}
|
||||
|
||||
if (!window.RTCSessionDescription) {
|
||||
window.RTCSessionDescription = SessionDescriptionMock;
|
||||
}
|
||||
|
||||
if (!window.RTCIceCandidate) {
|
||||
window.RTCIceCandidate = IceCandidateMock;
|
||||
}
|
||||
|
||||
if (!window.ResizeObserver) {
|
||||
window.ResizeObserver = ResizeObserverMock;
|
||||
}
|
||||
9885
WebApp/client/package-lock.json
generated
Normal file
9885
WebApp/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
WebApp/client/package.json
Normal file
18
WebApp/client/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "webclient",
|
||||
"version": "3.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
||||
"lint": "eslint public/**/*.js src/**/*.js test/**/*.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.23.0",
|
||||
"eslint-plugin-jest": "^27.0.1",
|
||||
"jest": "^29.0.2",
|
||||
"jest-dev-server": "^6.1.1",
|
||||
"jest-environment-jsdom": "^29.0.2",
|
||||
"node-fetch": "^3.2.10"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
54
WebApp/client/public/bidirectional/css/style.css
Normal file
54
WebApp/client/public/bidirectional/css/style.css
Normal file
@@ -0,0 +1,54 @@
|
||||
div#select, div#resolution {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0 20px 5px 0;
|
||||
vertical-align: top;
|
||||
width: 155px;
|
||||
}
|
||||
|
||||
div#buttons {
|
||||
border-top: 1px solid #eee;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin: 1em 0 1em 0;
|
||||
padding: 1em 0 1em 0;
|
||||
}
|
||||
|
||||
div#local {
|
||||
margin: 0 20px 0 0;
|
||||
}
|
||||
|
||||
div#preview {
|
||||
border-bottom: 1px solid #eee;
|
||||
margin: 0 0 1em 0;
|
||||
padding: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
div#preview>div {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: calc(50% - 20px);
|
||||
}
|
||||
|
||||
div#connectionId {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
color: #444;
|
||||
font-size: 0.9em;
|
||||
font-weight: 300;
|
||||
width: calc(20% - 10px);
|
||||
height: 1.3em;
|
||||
line-height: 1.3;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
video {
|
||||
height: 225px;
|
||||
}
|
||||
83
WebApp/client/public/bidirectional/index.html
Normal file
83
WebApp/client/public/bidirectional/index.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="../images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="../css/main.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<title>Bidirectional Sample</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="container">
|
||||
<h1>Bidirectional Sample</h1>
|
||||
|
||||
<div id="warning" hidden=true></div>
|
||||
|
||||
<div id="select">
|
||||
<label for="videoSource">Video source: </label>
|
||||
<select id="videoSource" autocomplete="off"></select>
|
||||
<label for="audioSource">Audio source: </label>
|
||||
<select id="audioSource" autocomplete="off"></select>
|
||||
</div>
|
||||
|
||||
<div id="resolutionSelect">
|
||||
<label for="videoResolution">Video resolution: </label><select id="videoResolution" autocomplete="off"></select>
|
||||
</div>
|
||||
<div id="resolutionInput">
|
||||
<label for="cameraWidth">Camera width:</label><input id="cameraWidth" type="number" min="0" max="4096" autocomplete="off" disabled>
|
||||
<label for="cameraHeight">Camera height:</label><input id="cameraHeight" type="number" min="0" max="4096" autocomplete="off" disabled>
|
||||
</div>
|
||||
|
||||
<div id="buttons">
|
||||
<button type="button" id="startVideoButton" autocomplete="off">Start Video</button>
|
||||
<button type="button" id="setUpButton" autocomplete="off" disabled>Set Up</button>
|
||||
<button type="button" id="hangUpButton" autocomplete="off" disabled>Hang Up</button>
|
||||
</div>
|
||||
|
||||
<div id="preview">
|
||||
<div id="local">
|
||||
<h2>Local</h2>
|
||||
<video id="localVideo" playsinline autoplay muted=true></video>
|
||||
<div id="localVideoStats"></div>
|
||||
</div>
|
||||
<div id="remote">
|
||||
<h2>Remote</h2>
|
||||
<video id="remoteVideo" playsinline autoplay></video>
|
||||
<div id="remoteVideoStats"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<span>Connection ID:</span>
|
||||
<textarea id="textForConnectionId"></textarea>
|
||||
</div>
|
||||
<div class="box">
|
||||
<span>Codec preferences:</span>
|
||||
<select id="codecPreferences" autocomplete="off" disabled>
|
||||
<option selected value="">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p>For more information about <code>Bidirectional</code> sample, see <a
|
||||
href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@3.1/manual/sample-bidirectional.html">Bidirectional
|
||||
sample</a> document page.</p>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp/public/bidirectional"
|
||||
title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
|
||||
<script src="https://unpkg.com/event-target@latest/min.js"></script>
|
||||
<script src="https://unpkg.com/resize-observer-polyfill@1.5.0/dist/ResizeObserver.global.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
289
WebApp/client/public/bidirectional/js/main.js
Normal file
289
WebApp/client/public/bidirectional/js/main.js
Normal file
@@ -0,0 +1,289 @@
|
||||
import { SendVideo } from "./sendvideo.js";
|
||||
import { getServerConfig, getRTCConfiguration } from "../../js/config.js";
|
||||
import { createDisplayStringArray } from "../../js/stats.js";
|
||||
import { RenderStreaming } from "../../module/renderstreaming.js";
|
||||
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
|
||||
|
||||
const defaultStreamWidth = 1280;
|
||||
const defaultStreamHeight = 720;
|
||||
const streamSizeList =
|
||||
[
|
||||
{ width: 640, height: 360 },
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 2560, height: 1440 },
|
||||
{ width: 3840, height: 2160 },
|
||||
{ width: 360, height: 640 },
|
||||
{ width: 720, height: 1280 },
|
||||
{ width: 1080, height: 1920 },
|
||||
{ width: 1440, height: 2560 },
|
||||
{ width: 2160, height: 3840 },
|
||||
];
|
||||
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
const remoteVideo = document.getElementById('remoteVideo');
|
||||
const localVideoStatsDiv = document.getElementById('localVideoStats');
|
||||
const remoteVideoStatsDiv = document.getElementById('remoteVideoStats');
|
||||
const textForConnectionId = document.getElementById('textForConnectionId');
|
||||
textForConnectionId.value = getRandom();
|
||||
const videoSelect = document.querySelector('select#videoSource');
|
||||
const audioSelect = document.querySelector('select#audioSource');
|
||||
const videoResolutionSelect = document.querySelector('select#videoResolution');
|
||||
const cameraWidthInput = document.querySelector('input#cameraWidth');
|
||||
const cameraHeightInput = document.querySelector('input#cameraHeight');
|
||||
|
||||
const codecPreferences = document.getElementById('codecPreferences');
|
||||
const supportsSetCodecPreferences = window.RTCRtpTransceiver &&
|
||||
'setCodecPreferences' in window.RTCRtpTransceiver.prototype;
|
||||
const messageDiv = document.getElementById('message');
|
||||
messageDiv.style.display = 'none';
|
||||
|
||||
let useCustomResolution = false;
|
||||
|
||||
setUpInputSelect();
|
||||
showCodecSelect();
|
||||
|
||||
/** @type {SendVideo} */
|
||||
let sendVideo = new SendVideo(localVideo, remoteVideo);
|
||||
/** @type {RenderStreaming} */
|
||||
let renderstreaming;
|
||||
let useWebSocket;
|
||||
let connectionId;
|
||||
|
||||
const startButton = document.getElementById('startVideoButton');
|
||||
startButton.addEventListener('click', startVideo);
|
||||
const setupButton = document.getElementById('setUpButton');
|
||||
setupButton.addEventListener('click', setUp);
|
||||
const hangUpButton = document.getElementById('hangUpButton');
|
||||
hangUpButton.addEventListener('click', hangUp);
|
||||
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
if(!renderstreaming)
|
||||
return;
|
||||
await renderstreaming.stop();
|
||||
}, true);
|
||||
|
||||
setupConfig();
|
||||
|
||||
async function setupConfig() {
|
||||
const res = await getServerConfig();
|
||||
useWebSocket = res.useWebSocket;
|
||||
showWarningIfNeeded(res.startupMode);
|
||||
}
|
||||
|
||||
function showWarningIfNeeded(startupMode) {
|
||||
const warningDiv = document.getElementById("warning");
|
||||
if (startupMode == "public") {
|
||||
warningDiv.innerHTML = "<h4>Warning</h4> This sample is not working on Public Mode.";
|
||||
warningDiv.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startVideo() {
|
||||
videoSelect.disabled = true;
|
||||
audioSelect.disabled = true;
|
||||
videoResolutionSelect.disabled = true;
|
||||
cameraWidthInput.disabled = true;
|
||||
cameraHeightInput.disabled = true;
|
||||
startButton.disabled = true;
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
if (useCustomResolution) {
|
||||
width = cameraWidthInput.value ? cameraWidthInput.value : defaultStreamWidth;
|
||||
height = cameraHeightInput.value ? cameraHeightInput.value : defaultStreamHeight;
|
||||
} else {
|
||||
const size = streamSizeList[videoResolutionSelect.value];
|
||||
width = size.width;
|
||||
height = size.height;
|
||||
}
|
||||
|
||||
await sendVideo.startLocalVideo(videoSelect.value, audioSelect.value, width, height);
|
||||
|
||||
// enable setup button after initializing local video.
|
||||
setupButton.disabled = false;
|
||||
}
|
||||
|
||||
async function setUp() {
|
||||
setupButton.disabled = true;
|
||||
hangUpButton.disabled = false;
|
||||
connectionId = textForConnectionId.value;
|
||||
codecPreferences.disabled = true;
|
||||
|
||||
const signaling = useWebSocket ? new WebSocketSignaling() : new Signaling();
|
||||
const config = getRTCConfiguration();
|
||||
renderstreaming = new RenderStreaming(signaling, config);
|
||||
renderstreaming.onConnect = () => {
|
||||
const tracks = sendVideo.getLocalTracks();
|
||||
for (const track of tracks) {
|
||||
renderstreaming.addTransceiver(track, { direction: 'sendonly' });
|
||||
}
|
||||
setCodecPreferences();
|
||||
showStatsMessage();
|
||||
};
|
||||
renderstreaming.onDisconnect = () => {
|
||||
hangUp();
|
||||
};
|
||||
renderstreaming.onTrackEvent = (data) => {
|
||||
const direction = data.transceiver.direction;
|
||||
if (direction == "sendrecv" || direction == "recvonly") {
|
||||
sendVideo.addRemoteTrack(data.track);
|
||||
}
|
||||
};
|
||||
|
||||
await renderstreaming.start();
|
||||
await renderstreaming.createConnection(connectionId);
|
||||
|
||||
}
|
||||
// 获取浏览器麦克风并发送到 Unity
|
||||
|
||||
function setCodecPreferences() {
|
||||
/** @type {RTCRtpCodecCapability[] | null} */
|
||||
let selectedCodecs = null;
|
||||
if (supportsSetCodecPreferences) {
|
||||
const preferredCodec = codecPreferences.options[codecPreferences.selectedIndex];
|
||||
if (preferredCodec.value !== '') {
|
||||
const [mimeType, sdpFmtpLine] = preferredCodec.value.split(' ');
|
||||
const { codecs } = RTCRtpSender.getCapabilities('video');
|
||||
const selectedCodecIndex = codecs.findIndex(c => c.mimeType === mimeType && c.sdpFmtpLine === sdpFmtpLine);
|
||||
const selectCodec = codecs[selectedCodecIndex];
|
||||
selectedCodecs = [selectCodec];
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCodecs == null) {
|
||||
return;
|
||||
}
|
||||
const transceivers = renderstreaming.getTransceivers().filter(t => t.receiver.track.kind == "video");
|
||||
if (transceivers && transceivers.length > 0) {
|
||||
transceivers.forEach(t => t.setCodecPreferences(selectedCodecs));
|
||||
}
|
||||
}
|
||||
|
||||
async function hangUp() {
|
||||
clearStatsMessage();
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerText = `Disconnect peer on ${connectionId}.`;
|
||||
|
||||
hangUpButton.disabled = true;
|
||||
setupButton.disabled = false;
|
||||
await renderstreaming.deleteConnection();
|
||||
await renderstreaming.stop();
|
||||
renderstreaming = null;
|
||||
remoteVideo.srcObject = null;
|
||||
|
||||
textForConnectionId.value = getRandom();
|
||||
connectionId = null;
|
||||
if (supportsSetCodecPreferences) {
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getRandom() {
|
||||
const max = 99999;
|
||||
const length = String(max).length;
|
||||
const number = Math.floor(Math.random() * max);
|
||||
return (Array(length).join('0') + number).slice(-length);
|
||||
}
|
||||
|
||||
async function setUpInputSelect() {
|
||||
const deviceInfos = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
for (let i = 0; i !== deviceInfos.length; ++i) {
|
||||
const deviceInfo = deviceInfos[i];
|
||||
if (deviceInfo.kind === 'videoinput') {
|
||||
const option = document.createElement('option');
|
||||
option.value = deviceInfo.deviceId;
|
||||
option.text = deviceInfo.label || `camera ${videoSelect.length + 1}`;
|
||||
videoSelect.appendChild(option);
|
||||
} else if (deviceInfo.kind === 'audioinput') {
|
||||
const option = document.createElement('option');
|
||||
option.value = deviceInfo.deviceId;
|
||||
option.text = deviceInfo.label || `mic ${audioSelect.length + 1}`;
|
||||
audioSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < streamSizeList.length; i++) {
|
||||
const streamSize = streamSizeList[i];
|
||||
const option = document.createElement('option');
|
||||
option.value = i;
|
||||
option.text = `${streamSize.width} x ${streamSize.height}`;
|
||||
videoResolutionSelect.appendChild(option);
|
||||
}
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = streamSizeList.length;
|
||||
option.text = 'Custom';
|
||||
videoResolutionSelect.appendChild(option);
|
||||
videoResolutionSelect.value = 1; // default select index (1280 x 720)
|
||||
|
||||
videoResolutionSelect.addEventListener('change', (event) => {
|
||||
const isCustom = event.target.value >= streamSizeList.length;
|
||||
cameraWidthInput.disabled = !isCustom;
|
||||
cameraHeightInput.disabled = !isCustom;
|
||||
useCustomResolution = isCustom;
|
||||
});
|
||||
}
|
||||
|
||||
function showCodecSelect() {
|
||||
if (!supportsSetCodecPreferences) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = `Current Browser does not support <a href="https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpTransceiver/setCodecPreferences">RTCRtpTransceiver.setCodecPreferences</a>.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const codecs = RTCRtpSender.getCapabilities('video').codecs;
|
||||
codecs.forEach(codec => {
|
||||
if (['video/red', 'video/ulpfec', 'video/rtx'].includes(codec.mimeType)) {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = (codec.mimeType + ' ' + (codec.sdpFmtpLine || '')).trim();
|
||||
option.innerText = option.value;
|
||||
codecPreferences.appendChild(option);
|
||||
});
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
|
||||
let lastStats;
|
||||
let intervalId;
|
||||
|
||||
function showStatsMessage() {
|
||||
intervalId = setInterval(async () => {
|
||||
if (localVideo.videoWidth) {
|
||||
localVideoStatsDiv.innerHTML = `<strong>Sending resolution:</strong> ${localVideo.videoWidth} x ${localVideo.videoHeight} px`;
|
||||
}
|
||||
if (remoteVideo.videoWidth) {
|
||||
remoteVideoStatsDiv.innerHTML = `<strong>Receiving resolution:</strong> ${remoteVideo.videoWidth} x ${remoteVideo.videoHeight} px`;
|
||||
}
|
||||
|
||||
if (renderstreaming == null || connectionId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await renderstreaming.getStats();
|
||||
if (stats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const array = createDisplayStringArray(stats, lastStats);
|
||||
if (array.length) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = array.join('<br>');
|
||||
}
|
||||
lastStats = stats;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearStatsMessage() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
lastStats = null;
|
||||
intervalId = null;
|
||||
localVideoStatsDiv.innerHTML = '';
|
||||
remoteVideoStatsDiv.innerHTML = '';
|
||||
messageDiv.style.display = 'none';
|
||||
messageDiv.innerHTML = '';
|
||||
}
|
||||
53
WebApp/client/public/bidirectional/js/sendvideo.js
Normal file
53
WebApp/client/public/bidirectional/js/sendvideo.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as Logger from "../../module/logger.js";
|
||||
|
||||
export class SendVideo {
|
||||
constructor(localVideoElement, remoteVideoElement) {
|
||||
this.localVideo = localVideoElement;
|
||||
this.remoteVideo = remoteVideoElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MediaTrackConstraints} videoSource
|
||||
* @param {MediaTrackConstraints} audioSource
|
||||
* @param {number} videoWidth
|
||||
* @param {number} videoHeight
|
||||
*/
|
||||
async startLocalVideo(videoSource, audioSource, videoWidth, videoHeight) {
|
||||
try {
|
||||
const constraints = {
|
||||
video: { deviceId: videoSource ? { exact: videoSource } : undefined },
|
||||
audio: { deviceId: audioSource ? { exact: audioSource } : undefined }
|
||||
};
|
||||
|
||||
if (videoWidth != null || videoWidth != 0) {
|
||||
constraints.video.width = videoWidth;
|
||||
}
|
||||
if (videoHeight != null || videoHeight != 0) {
|
||||
constraints.video.height = videoHeight;
|
||||
}
|
||||
|
||||
const localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.localVideo.srcObject = localStream;
|
||||
await this.localVideo.play();
|
||||
} catch (err) {
|
||||
Logger.error(`mediaDevice.getUserMedia() error:${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {MediaStreamTrack[]}
|
||||
*/
|
||||
getLocalTracks() {
|
||||
return this.localVideo.srcObject.getTracks();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MediaStreamTrack} track
|
||||
*/
|
||||
addRemoteTrack(track) {
|
||||
if (this.remoteVideo.srcObject == null) {
|
||||
this.remoteVideo.srcObject = new MediaStream();
|
||||
}
|
||||
this.remoteVideo.srcObject.addTrack(track);
|
||||
}
|
||||
}
|
||||
147
WebApp/client/public/css/main.css
Normal file
147
WebApp/client/public/css/main.css
Normal file
@@ -0,0 +1,147 @@
|
||||
h1 {
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.8em 0;
|
||||
padding: 0 0 0.2em 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
padding: 0 0 0.2em 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-weight: 300;
|
||||
margin: 0;
|
||||
padding: 1em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 20px 10px 0 0;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
button#gather {
|
||||
display: block;
|
||||
}
|
||||
|
||||
section {
|
||||
border-bottom: 1px solid #eee;
|
||||
margin: 0 0 1.5em 0;
|
||||
padding: 0 0 1.5em 0;
|
||||
}
|
||||
|
||||
section#iceServers label {
|
||||
display: inline-block;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
section#iceServers input {
|
||||
margin: 0 0 10px;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
select {
|
||||
margin: 0 1em 1em 0;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
select#servers {
|
||||
font-size: 1em;
|
||||
padding: 5px;
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
section:last-child {
|
||||
border-bottom: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
div#container {
|
||||
margin: 0 auto 0 auto;
|
||||
max-width: 60em;
|
||||
padding: 1em 1.5em 1.3em 1.5em;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.1em 0.25em;
|
||||
color: #444;
|
||||
background-color: #e7edf3;
|
||||
border-radius: 3px;
|
||||
border: solid 1px #d6dde4;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #444;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
p#data {
|
||||
border-top: 1px dotted #666;
|
||||
line-height: 1.3em;
|
||||
max-height: 1000px;
|
||||
overflow-y: auto;
|
||||
padding: 1em 0 0 0;
|
||||
}
|
||||
|
||||
p.borderBelow {
|
||||
border-bottom: 1px solid #aaa;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
video {
|
||||
background: #222;
|
||||
margin: 0 0 20px 0;
|
||||
--width: 100%;
|
||||
width: var(--width);
|
||||
height: calc(var(--width) * 0.75);
|
||||
}
|
||||
|
||||
div#warning {
|
||||
color: #8a6d3b;
|
||||
background-color: #fcf8e3;
|
||||
border-color: #faebcc;
|
||||
padding: 1em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
div.box {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
div#message {
|
||||
border-top: 1px solid #666;
|
||||
margin: 1em;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.highlight {
|
||||
font-size: 1em;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 0.2em 1em;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 550px) {
|
||||
button:active {
|
||||
background-color: darkRed;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
BIN
WebApp/client/public/images/FullScreen.png
Normal file
BIN
WebApp/client/public/images/FullScreen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
WebApp/client/public/images/Play.png
Normal file
BIN
WebApp/client/public/images/Play.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 472 KiB |
BIN
WebApp/client/public/images/favicon.ico
Normal file
BIN
WebApp/client/public/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
77
WebApp/client/public/index.html
Normal file
77
WebApp/client/public/index.html
Normal file
@@ -0,0 +1,77 @@
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="css/main.css" />
|
||||
<title>Unity Render Streaming Samples</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="container">
|
||||
<h1>Unity Render Streaming Samples</h1>
|
||||
|
||||
<section>
|
||||
<p>These are WebClient samples for use with <a href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@latest/index.html">Unity Render
|
||||
Streaming</a>.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Server Configuration</h2>
|
||||
<div id="startup"></div>
|
||||
</section>
|
||||
|
||||
<section id="iceServers">
|
||||
<h2>ICE servers</h2>
|
||||
<select id="servers" size="4">
|
||||
</select>
|
||||
<div>
|
||||
<label for="url">STUN or TURN URI:</label>
|
||||
<input id="url">
|
||||
</div>
|
||||
<div>
|
||||
<label for="username">TURN username:</label>
|
||||
<input id="username">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">TURN password:</label>
|
||||
<input id="password">
|
||||
</div>
|
||||
<div>
|
||||
<button id="add">Add Server</button>
|
||||
<button id="remove">Remove Server</button>
|
||||
<button id="reset">Reset to defaults</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 id="receiver"><a href="receiver/index.html">Receiver Sample</a></h2>
|
||||
<p>This is a sample for receiving video / audio from Unity.</p>
|
||||
<p>It can be used in combination with the <code>Broadcast</code> scene of Unity Render Streaming.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 id="bidirectional"><a href="bidirectional/index.html">Bidirectional Sample</a></h2>
|
||||
<p>This is a sample for sending and receiving video in both directions.</p>
|
||||
<p>It can be used in combination with the <code>Bidirectional</code> scene of Unity Render Streaming.</p>
|
||||
<p>The WebApp must be running in Private mode.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 id="multiplay"><a href="multiplay/index.html">Multiplay Sample</a></h2>
|
||||
<p>This sample connects as a Guest in the <code>Multiplay</code> scene of Unity Render Streaming.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 id="videoplayer"><a href="videoplayer/index.html">VideoPlayer Sample</a></h2>
|
||||
<p>This is a sample to receive the camera image rendered on Unity. You can operate the camera in Unity from the
|
||||
browser.</p>
|
||||
<p>It can be used in combination with the <code>WebBrowserInput</code> scene of Unity Render Streaming.</p>
|
||||
<p>The WebApp must be running in Public mode.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp" title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
14
WebApp/client/public/js/config.js
Normal file
14
WebApp/client/public/js/config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import {getServers} from "./icesettings.js";
|
||||
|
||||
export async function getServerConfig() {
|
||||
const protocolEndPoint = location.origin + '/config';
|
||||
const createResponse = await fetch(protocolEndPoint);
|
||||
return await createResponse.json();
|
||||
}
|
||||
|
||||
export function getRTCConfiguration() {
|
||||
let config = {};
|
||||
config.sdpSemantics = 'unified-plan';
|
||||
config.iceServers = getServers();
|
||||
return config;
|
||||
}
|
||||
103
WebApp/client/public/js/icesettings.js
Normal file
103
WebApp/client/public/js/icesettings.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// This code is referenced from webrtc sample.
|
||||
// https://github.com/webrtc/samples/blob/gh-pages/src/content/peerconnection/trickle-ice/js/main.js
|
||||
|
||||
const servers = document.querySelector('select#servers');
|
||||
const urlInput = document.querySelector('input#url');
|
||||
const usernameInput = document.querySelector('input#username');
|
||||
const passwordInput = document.querySelector('input#password');
|
||||
|
||||
const allServersKey = 'servers';
|
||||
|
||||
export function addServer() {
|
||||
const scheme = urlInput.value.split(':')[0];
|
||||
if (!['stun', 'stuns', 'turn', 'turns'].includes(scheme)) {
|
||||
alert(`URI scheme ${scheme} is not valid`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the ICE server as a stringified JSON object in option.value.
|
||||
const option = document.createElement('option');
|
||||
const iceServer = {
|
||||
urls: [urlInput.value],
|
||||
username: usernameInput.value,
|
||||
credential: passwordInput.value
|
||||
};
|
||||
option.value = JSON.stringify(iceServer);
|
||||
option.text = `${urlInput.value} `;
|
||||
const username = usernameInput.value;
|
||||
const password = passwordInput.value;
|
||||
if (username || password) {
|
||||
option.text += (` [${username}:${password}]`);
|
||||
}
|
||||
option.ondblclick = selectServer;
|
||||
servers.add(option);
|
||||
urlInput.value = usernameInput.value = passwordInput.value = '';
|
||||
writeServersToLocalStorage();
|
||||
}
|
||||
|
||||
export function removeServer() {
|
||||
for (let i = servers.options.length - 1; i >= 0; --i) {
|
||||
if (servers.options[i].selected) {
|
||||
servers.remove(i);
|
||||
}
|
||||
}
|
||||
writeServersToLocalStorage();
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
window.localStorage.clear();
|
||||
document.querySelectorAll('select#servers option').forEach(option => option.remove());
|
||||
const serversSelect = document.querySelector('select#servers');
|
||||
setDefaultServer(serversSelect);
|
||||
}
|
||||
|
||||
function selectServer(event) {
|
||||
const option = event.target;
|
||||
const value = JSON.parse(option.value);
|
||||
urlInput.value = value.urls[0];
|
||||
usernameInput.value = value.username || '';
|
||||
passwordInput.value = value.credential || '';
|
||||
}
|
||||
|
||||
function setDefaultServer(serversSelect) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '{"urls":["stun:stun.l.google.com:19302"]}';
|
||||
option.text = 'stun:stun.l.google.com:19302';
|
||||
option.ondblclick = selectServer;
|
||||
serversSelect.add(option);
|
||||
}
|
||||
|
||||
function writeServersToLocalStorage() {
|
||||
const serversSelect = document.querySelector('select#servers');
|
||||
const allServers = JSON.stringify(Object.values(serversSelect.options).map(o => JSON.parse(o.value)));
|
||||
window.localStorage.setItem(allServersKey, allServers);
|
||||
}
|
||||
|
||||
export function readServersFromLocalStorage() {
|
||||
document.querySelectorAll('select#servers option').forEach(option => option.remove());
|
||||
const serversSelect = document.querySelector('select#servers');
|
||||
const storedServers = window.localStorage.getItem(allServersKey);
|
||||
|
||||
if (storedServers === null || storedServers === '') {
|
||||
setDefaultServer(serversSelect);
|
||||
} else {
|
||||
JSON.parse(storedServers).forEach((server) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = JSON.stringify(server);
|
||||
o.text = server.urls[0];
|
||||
o.ondblclick = selectServer;
|
||||
serversSelect.add(o);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getServers() {
|
||||
const storedServers = window.localStorage.getItem(allServersKey);
|
||||
|
||||
if (storedServers === null || storedServers === '') {
|
||||
return [{ urls: ['stun:stun.l.google.com:19302'] }];
|
||||
}
|
||||
else {
|
||||
return JSON.parse(storedServers);
|
||||
}
|
||||
}
|
||||
27
WebApp/client/public/js/main.js
Normal file
27
WebApp/client/public/js/main.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as Config from "./config.js";
|
||||
import {addServer, removeServer, reset, readServersFromLocalStorage} from "./icesettings.js";
|
||||
|
||||
const addButton = document.querySelector('button#add');
|
||||
const removeButton = document.querySelector('button#remove');
|
||||
const resetButton = document.querySelector('button#reset');
|
||||
const startupDiv = document.getElementById("startup");
|
||||
|
||||
addButton.onclick = addServer;
|
||||
removeButton.onclick = removeServer;
|
||||
resetButton.onclick = reset;
|
||||
startupDiv.innerHTML = "";
|
||||
|
||||
const displayConfig = async () => {
|
||||
const res = await Config.getServerConfig();
|
||||
if (res.useWebSocket) {
|
||||
startupDiv.innerHTML += "<li>Signaling Protocol : <b>WebSocket</b></li>";
|
||||
} else {
|
||||
startupDiv.innerHTML += "<li>Signaling Protocol : <b>HTTP</b></li>";
|
||||
}
|
||||
|
||||
const mode = res.startupMode.replace(/^./, res.startupMode[0].toUpperCase());
|
||||
startupDiv.innerHTML += `<li>Signaling Mode : <b>${mode}</b></li>`;
|
||||
};
|
||||
|
||||
displayConfig();
|
||||
readServersFromLocalStorage();
|
||||
91
WebApp/client/public/js/stats.js
Normal file
91
WebApp/client/public/js/stats.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* create display string array from RTCStatsReport
|
||||
* @param {RTCStatsReport} report - current RTCStatsReport
|
||||
* @param {RTCStatsReport} lastReport - latest RTCStatsReport
|
||||
* @return {Array<string>} - display string Array
|
||||
*/
|
||||
export function createDisplayStringArray(report, lastReport) {
|
||||
let array = new Array();
|
||||
|
||||
report.forEach(stat => {
|
||||
if (stat.type === 'inbound-rtp') {
|
||||
array.push(`${stat.kind} receiving stream stats`);
|
||||
|
||||
if (stat.codecId != undefined) {
|
||||
const codec = report.get(stat.codecId);
|
||||
array.push(`Codec: ${codec.mimeType}`);
|
||||
|
||||
if (codec.sdpFmtpLine) {
|
||||
codec.sdpFmtpLine.split(";").forEach(fmtp => {
|
||||
array.push(` - ${fmtp}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (codec.payloadType) {
|
||||
array.push(` - payloadType=${codec.payloadType}`);
|
||||
}
|
||||
|
||||
if (codec.clockRate) {
|
||||
array.push(` - clockRate=${codec.clockRate}`);
|
||||
}
|
||||
|
||||
if (codec.channels) {
|
||||
array.push(` - channels=${codec.channels}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stat.kind == "video") {
|
||||
array.push(`Decoder: ${stat.decoderImplementation}`);
|
||||
array.push(`Resolution: ${stat.frameWidth}x${stat.frameHeight}`);
|
||||
array.push(`Framerate: ${stat.framesPerSecond}`);
|
||||
}
|
||||
|
||||
if (lastReport && lastReport.has(stat.id)) {
|
||||
const lastStats = lastReport.get(stat.id);
|
||||
const duration = (stat.timestamp - lastStats.timestamp) / 1000;
|
||||
const bitrate = (8 * (stat.bytesReceived - lastStats.bytesReceived) / duration) / 1000;
|
||||
array.push(`Bitrate: ${bitrate.toFixed(2)} kbit/sec`);
|
||||
}
|
||||
} else if (stat.type === 'outbound-rtp') {
|
||||
array.push(`${stat.kind} sending stream stats`);
|
||||
|
||||
if (stat.codecId != undefined) {
|
||||
const codec = report.get(stat.codecId);
|
||||
array.push(`Codec: ${codec.mimeType}`);
|
||||
|
||||
if (codec.sdpFmtpLine) {
|
||||
codec.sdpFmtpLine.split(";").forEach(fmtp => {
|
||||
array.push(` - ${fmtp}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (codec.payloadType) {
|
||||
array.push(` - payloadType=${codec.payloadType}`);
|
||||
}
|
||||
|
||||
if (codec.clockRate) {
|
||||
array.push(` - clockRate=${codec.clockRate}`);
|
||||
}
|
||||
|
||||
if (codec.channels) {
|
||||
array.push(` - channels=${codec.channels}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stat.kind == "video") {
|
||||
array.push(`Encoder: ${stat.encoderImplementation}`);
|
||||
array.push(`Resolution: ${stat.frameWidth}x${stat.frameHeight}`);
|
||||
array.push(`Framerate: ${stat.framesPerSecond}`);
|
||||
}
|
||||
|
||||
if (lastReport && lastReport.has(stat.id)) {
|
||||
const lastStats = lastReport.get(stat.id);
|
||||
const duration = (stat.timestamp - lastStats.timestamp) / 1000;
|
||||
const bitrate = (8 * (stat.bytesSent - lastStats.bytesSent) / duration) / 1000;
|
||||
array.push(`Bitrate: ${bitrate.toFixed(2)} kbit/sec`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return array;
|
||||
}
|
||||
213
WebApp/client/public/js/videoplayer.js
Normal file
213
WebApp/client/public/js/videoplayer.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Observer, Sender } from "../module/sender.js";
|
||||
import { InputRemoting } from "../module/inputremoting.js";
|
||||
|
||||
export class VideoPlayer {
|
||||
constructor() {
|
||||
this.playerElement = null;
|
||||
this.lockMouseCheck = null;
|
||||
this.videoElement = null;
|
||||
this.fullScreenButtonElement = null;
|
||||
this.inputRemoting = null;
|
||||
this.sender = null;
|
||||
this.inputSenderChannel = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} playerElement parent element for create video player
|
||||
* @param {HTMLInputElement} lockMouseCheck use checked propety for lock mouse
|
||||
*/
|
||||
createPlayer(playerElement, lockMouseCheck) {
|
||||
this.playerElement = playerElement;
|
||||
this.lockMouseCheck = lockMouseCheck;
|
||||
|
||||
this.videoElement = document.createElement('video');
|
||||
this.videoElement.id = 'Video';
|
||||
this.videoElement.style.touchAction = 'none';
|
||||
this.videoElement.playsInline = true;
|
||||
this.videoElement.srcObject = new MediaStream();
|
||||
this.videoElement.addEventListener('loadedmetadata', this._onLoadedVideo.bind(this), true);
|
||||
this.playerElement.appendChild(this.videoElement);
|
||||
|
||||
// add fullscreen button
|
||||
this.fullScreenButtonElement = document.createElement('img');
|
||||
this.fullScreenButtonElement.id = 'fullscreenButton';
|
||||
this.fullScreenButtonElement.src = '../images/FullScreen.png';
|
||||
this.fullScreenButtonElement.addEventListener("click", this._onClickFullscreenButton.bind(this));
|
||||
this.playerElement.appendChild(this.fullScreenButtonElement);
|
||||
|
||||
document.addEventListener('webkitfullscreenchange', this._onFullscreenChange.bind(this));
|
||||
document.addEventListener('fullscreenchange', this._onFullscreenChange.bind(this));
|
||||
this.videoElement.addEventListener("click", this._mouseClick.bind(this), false);
|
||||
}
|
||||
|
||||
_onLoadedVideo() {
|
||||
this.videoElement.play();
|
||||
this.resizeVideo();
|
||||
}
|
||||
|
||||
_onClickFullscreenButton() {
|
||||
if (!document.fullscreenElement || !document.webkitFullscreenElement) {
|
||||
if (document.documentElement.requestFullscreen) {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
else if (document.documentElement.webkitRequestFullscreen) {
|
||||
document.documentElement.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
|
||||
} else {
|
||||
if (this.playerElement.style.position == "absolute") {
|
||||
this.playerElement.style.position = "relative";
|
||||
} else {
|
||||
this.playerElement.style.position = "absolute";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onFullscreenChange() {
|
||||
if (document.webkitFullscreenElement || document.fullscreenElement) {
|
||||
this.playerElement.style.position = "absolute";
|
||||
this.fullScreenButtonElement.style.display = 'none';
|
||||
|
||||
if (this.lockMouseCheck.checked) {
|
||||
if (document.webkitFullscreenElement.requestPointerLock) {
|
||||
document.webkitFullscreenElement.requestPointerLock();
|
||||
} else if (document.fullscreenElement.requestPointerLock) {
|
||||
document.fullscreenElement.requestPointerLock();
|
||||
} else if (document.mozFullScreenElement.requestPointerLock) {
|
||||
document.mozFullScreenElement.requestPointerLock();
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
document.addEventListener('mousemove', this._mouseMove.bind(this), false);
|
||||
document.addEventListener('click', this._mouseClickFullScreen.bind(this), false);
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.playerElement.style.position = "relative";
|
||||
this.fullScreenButtonElement.style.display = 'block';
|
||||
|
||||
document.removeEventListener('mousemove', this._mouseMove.bind(this), false);
|
||||
document.removeEventListener('click', this._mouseClickFullScreen.bind(this), false);
|
||||
}
|
||||
}
|
||||
|
||||
_mouseMove(event) {
|
||||
// Forward mouseMove event of fullscreen player directly to sender
|
||||
// This is required, as the regular mousemove event doesn't fire when in fullscreen mode
|
||||
this.sender._onMouseEvent(event);
|
||||
}
|
||||
|
||||
_mouseClick() {
|
||||
// Restores pointer lock when we unfocus the player and click on it again
|
||||
if (this.lockMouseCheck.checked) {
|
||||
if (this.videoElement.requestPointerLock) {
|
||||
this.videoElement.requestPointerLock().catch(function () { });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_mouseClickFullScreen() {
|
||||
// Restores pointer lock when we unfocus the fullscreen player and click on it again
|
||||
if (this.lockMouseCheck.checked) {
|
||||
if (document.webkitFullscreenElement.requestPointerLock) {
|
||||
document.webkitFullscreenElement.requestPointerLock();
|
||||
} else if (document.fullscreenElement.requestPointerLock) {
|
||||
document.fullscreenElement.requestPointerLock();
|
||||
} else if (document.mozFullScreenElement.requestPointerLock) {
|
||||
document.mozFullScreenElement.requestPointerLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MediaStreamTrack} track
|
||||
*/
|
||||
addTrack(track) {
|
||||
if (!this.videoElement.srcObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoElement.srcObject.addTrack(track);
|
||||
}
|
||||
|
||||
resizeVideo() {
|
||||
if (!this.videoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientRect = this.videoElement.getBoundingClientRect();
|
||||
const videoRatio = this.videoWidth / this.videoHeight;
|
||||
const clientRatio = clientRect.width / clientRect.height;
|
||||
|
||||
this._videoScale = videoRatio > clientRatio ? clientRect.width / this.videoWidth : clientRect.height / this.videoHeight;
|
||||
const videoOffsetX = videoRatio > clientRatio ? 0 : (clientRect.width - this.videoWidth * this._videoScale) * 0.5;
|
||||
const videoOffsetY = videoRatio > clientRatio ? (clientRect.height - this.videoHeight * this._videoScale) * 0.5 : 0;
|
||||
this._videoOriginX = clientRect.left + videoOffsetX;
|
||||
this._videoOriginY = clientRect.top + videoOffsetY;
|
||||
}
|
||||
|
||||
get videoWidth() {
|
||||
return this.videoElement.videoWidth;
|
||||
}
|
||||
|
||||
get videoHeight() {
|
||||
return this.videoElement.videoHeight;
|
||||
}
|
||||
|
||||
get videoOriginX() {
|
||||
return this._videoOriginX;
|
||||
}
|
||||
|
||||
get videoOriginY() {
|
||||
return this._videoOriginY;
|
||||
}
|
||||
|
||||
get videoScale() {
|
||||
return this._videoScale;
|
||||
}
|
||||
|
||||
deletePlayer() {
|
||||
if (this.inputRemoting) {
|
||||
this.inputRemoting.stopSending();
|
||||
}
|
||||
this.inputRemoting = null;
|
||||
this.sender = null;
|
||||
this.inputSenderChannel = null;
|
||||
|
||||
while (this.playerElement.firstChild) {
|
||||
this.playerElement.removeChild(this.playerElement.firstChild);
|
||||
}
|
||||
|
||||
this.playerElement = null;
|
||||
this.lockMouseCheck = null;
|
||||
}
|
||||
|
||||
_isTouchDevice() {
|
||||
return (('ontouchstart' in window) ||
|
||||
(navigator.maxTouchPoints > 0) ||
|
||||
(navigator.msMaxTouchPoints > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* setup datachannel for player input (muouse/keyboard/touch/gamepad)
|
||||
* @param {RTCDataChannel} channel
|
||||
*/
|
||||
setupInput(channel) {
|
||||
this.sender = new Sender(this.videoElement);
|
||||
this.sender.addMouse();
|
||||
this.sender.addKeyboard();
|
||||
if (this._isTouchDevice()) {
|
||||
this.sender.addTouchscreen();
|
||||
}
|
||||
this.sender.addGamepad();
|
||||
this.inputRemoting = new InputRemoting(this.sender);
|
||||
|
||||
this.inputSenderChannel = channel;
|
||||
this.inputSenderChannel.onopen = this._onOpenInputSenderChannel.bind(this);
|
||||
this.inputRemoting.subscribe(new Observer(this.inputSenderChannel));
|
||||
}
|
||||
|
||||
async _onOpenInputSenderChannel() {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
this.inputRemoting.startSending();
|
||||
}
|
||||
}
|
||||
99
WebApp/client/public/multiplay/css/style.css
Normal file
99
WebApp/client/public/multiplay/css/style.css
Normal file
@@ -0,0 +1,99 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
#player {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
background-color: #323232;
|
||||
}
|
||||
|
||||
#player:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 66%;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
width: 15%;
|
||||
max-width: 200px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#Video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#VideoThumbnail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
}
|
||||
|
||||
#greenButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
width: 160px;
|
||||
background-color: #4CAF50;
|
||||
/* Green */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#blueButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 180px;
|
||||
width: 160px;
|
||||
background-color: #447FAF;
|
||||
/* Blue */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#orangeButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 350px;
|
||||
width: 160px;
|
||||
background-color: #FF7700;
|
||||
/* Blue */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#fullscreenButton {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 25px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
53
WebApp/client/public/multiplay/index.html
Normal file
53
WebApp/client/public/multiplay/index.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="../images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="../css/main.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<title>Multiplay Sample</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="container">
|
||||
<h1>Multiplay Sample</h1>
|
||||
|
||||
<div id="warning" hidden="true"></div>
|
||||
|
||||
<div id="player"></div>
|
||||
|
||||
<div class="box">
|
||||
<span>Codec preferences:</span>
|
||||
<select id="codecPreferences" autocomplete="off" disabled>
|
||||
<option selected value="">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<span>Lock Cursor to Player:</span>
|
||||
<input type="checkbox" id="lockMouseCheck" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
For more information about sample, see <a
|
||||
href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@3.1/manual/sample-multiplay.html">Multiplay sample</a> document page.
|
||||
</p>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp/client/public/multiplay"
|
||||
title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
|
||||
<script src="https://unpkg.com/event-target@latest/min.js"></script>
|
||||
<script src="https://unpkg.com/resize-observer-polyfill@1.5.0/dist/ResizeObserver.global.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
203
WebApp/client/public/multiplay/js/main.js
Normal file
203
WebApp/client/public/multiplay/js/main.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { getServerConfig, getRTCConfiguration } from "../../js/config.js";
|
||||
import { createDisplayStringArray } from "../../js/stats.js";
|
||||
import { VideoPlayer } from "../../js/videoplayer.js";
|
||||
import { RenderStreaming } from "../../module/renderstreaming.js";
|
||||
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
|
||||
|
||||
/** @enum {number} */
|
||||
const ActionType = {
|
||||
ChangeLabel: 0
|
||||
};
|
||||
|
||||
/** @type {Element} */
|
||||
let playButton;
|
||||
/** @type {RenderStreaming} */
|
||||
let renderstreaming;
|
||||
/** @type {boolean} */
|
||||
let useWebSocket;
|
||||
/** @type {RTCDataChannel} */
|
||||
let multiplayChannel;
|
||||
|
||||
const codecPreferences = document.getElementById('codecPreferences');
|
||||
const supportsSetCodecPreferences = window.RTCRtpTransceiver &&
|
||||
'setCodecPreferences' in window.RTCRtpTransceiver.prototype;
|
||||
const messageDiv = document.getElementById('message');
|
||||
messageDiv.style.display = 'none';
|
||||
|
||||
const playerDiv = document.getElementById('player');
|
||||
const lockMouseCheck = document.getElementById('lockMouseCheck');
|
||||
const videoPlayer = new VideoPlayer();
|
||||
|
||||
setup();
|
||||
|
||||
window.document.oncontextmenu = function () {
|
||||
return false; // cancel default menu
|
||||
};
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
videoPlayer.resizeVideo();
|
||||
}, true);
|
||||
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
if(!renderstreaming)
|
||||
return;
|
||||
await renderstreaming.stop();
|
||||
}, true);
|
||||
|
||||
async function setup() {
|
||||
const res = await getServerConfig();
|
||||
useWebSocket = res.useWebSocket;
|
||||
showWarningIfNeeded(res.startupMode);
|
||||
showCodecSelect();
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function showWarningIfNeeded(startupMode) {
|
||||
const warningDiv = document.getElementById("warning");
|
||||
if (startupMode == "private") {
|
||||
warningDiv.innerHTML = "<h4>Warning</h4> This sample is not working on Private Mode.";
|
||||
warningDiv.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPlayButton() {
|
||||
if (!document.getElementById('playButton')) {
|
||||
const elementPlayButton = document.createElement('img');
|
||||
elementPlayButton.id = 'playButton';
|
||||
elementPlayButton.src = '../../images/Play.png';
|
||||
elementPlayButton.alt = 'Start Streaming';
|
||||
playButton = document.getElementById('player').appendChild(elementPlayButton);
|
||||
playButton.addEventListener('click', onClickPlayButton);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickPlayButton() {
|
||||
playButton.style.display = 'none';
|
||||
|
||||
// add video player
|
||||
videoPlayer.createPlayer(playerDiv, lockMouseCheck);
|
||||
setupRenderStreaming();
|
||||
}
|
||||
|
||||
async function setupRenderStreaming() {
|
||||
codecPreferences.disabled = true;
|
||||
|
||||
const signaling = useWebSocket ? new WebSocketSignaling() : new Signaling();
|
||||
const config = getRTCConfiguration();
|
||||
renderstreaming = new RenderStreaming(signaling, config);
|
||||
renderstreaming.onConnect = onConnect;
|
||||
renderstreaming.onDisconnect = onDisconnect;
|
||||
renderstreaming.onTrackEvent = (data) => videoPlayer.addTrack(data.track);
|
||||
renderstreaming.onGotOffer = setCodecPreferences;
|
||||
|
||||
await renderstreaming.start();
|
||||
await renderstreaming.createConnection();
|
||||
}
|
||||
|
||||
function onConnect() {
|
||||
const channel = renderstreaming.createDataChannel("input");
|
||||
videoPlayer.setupInput(channel);
|
||||
multiplayChannel = renderstreaming.createDataChannel("multiplay");
|
||||
multiplayChannel.onopen = onOpenMultiplayChannel;
|
||||
showStatsMessage();
|
||||
}
|
||||
|
||||
async function onOpenMultiplayChannel() {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const num = Math.floor(Math.random() * 100000);
|
||||
const json = JSON.stringify({ type: ActionType.ChangeLabel, argument: String(num) });
|
||||
multiplayChannel.send(json);
|
||||
}
|
||||
|
||||
async function onDisconnect(connectionId) {
|
||||
clearStatsMessage();
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerText = `Disconnect peer on ${connectionId}.`;
|
||||
|
||||
await renderstreaming.stop();
|
||||
renderstreaming = null;
|
||||
multiplayChannel = null;
|
||||
videoPlayer.deletePlayer();
|
||||
if (supportsSetCodecPreferences) {
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function setCodecPreferences() {
|
||||
/** @type {RTCRtpCodecCapability[] | null} */
|
||||
let selectedCodecs = null;
|
||||
if (supportsSetCodecPreferences) {
|
||||
const preferredCodec = codecPreferences.options[codecPreferences.selectedIndex];
|
||||
if (preferredCodec.value !== '') {
|
||||
const [mimeType, sdpFmtpLine] = preferredCodec.value.split(' ');
|
||||
const { codecs } = RTCRtpSender.getCapabilities('video');
|
||||
const selectedCodecIndex = codecs.findIndex(c => c.mimeType === mimeType && c.sdpFmtpLine === sdpFmtpLine);
|
||||
const selectCodec = codecs[selectedCodecIndex];
|
||||
selectedCodecs = [selectCodec];
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCodecs == null) {
|
||||
return;
|
||||
}
|
||||
const transceivers = renderstreaming.getTransceivers().filter(t => t.receiver.track.kind == "video");
|
||||
if (transceivers && transceivers.length > 0) {
|
||||
transceivers.forEach(t => t.setCodecPreferences(selectedCodecs));
|
||||
}
|
||||
}
|
||||
|
||||
function showCodecSelect() {
|
||||
if (!supportsSetCodecPreferences) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = `Current Browser does not support <a href="https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpTransceiver/setCodecPreferences">RTCRtpTransceiver.setCodecPreferences</a>.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const codecs = RTCRtpSender.getCapabilities('video').codecs;
|
||||
codecs.forEach(codec => {
|
||||
if (['video/red', 'video/ulpfec', 'video/rtx'].includes(codec.mimeType)) {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = (codec.mimeType + ' ' + (codec.sdpFmtpLine || '')).trim();
|
||||
option.innerText = option.value;
|
||||
codecPreferences.appendChild(option);
|
||||
});
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
|
||||
/** @type {RTCStatsReport} */
|
||||
let lastStats;
|
||||
/** @type {number} */
|
||||
let intervalId;
|
||||
|
||||
function showStatsMessage() {
|
||||
intervalId = setInterval(async () => {
|
||||
if (renderstreaming == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await renderstreaming.getStats();
|
||||
if (stats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const array = createDisplayStringArray(stats, lastStats);
|
||||
if (array.length) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = array.join('<br>');
|
||||
}
|
||||
lastStats = stats;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearStatsMessage() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
lastStats = null;
|
||||
intervalId = null;
|
||||
messageDiv.style.display = 'none';
|
||||
messageDiv.innerHTML = '';
|
||||
}
|
||||
43
WebApp/client/public/receiver/css/style.css
Normal file
43
WebApp/client/public/receiver/css/style.css
Normal file
@@ -0,0 +1,43 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
#player {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
background-color: #323232;
|
||||
}
|
||||
|
||||
#player:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 66%;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
width: 15%;
|
||||
max-width: 200px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#Video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#fullscreenButton {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 25px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
53
WebApp/client/public/receiver/index.html
Normal file
53
WebApp/client/public/receiver/index.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="../images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="../css/main.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<title>Receiver Sample</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="container">
|
||||
<h1>Receiver Sample</h1>
|
||||
|
||||
<div id="warning" hidden="true"></div>
|
||||
|
||||
<div id="player"></div>
|
||||
|
||||
<div class="box">
|
||||
<span>Codec preferences:</span>
|
||||
<select id="codecPreferences" autocomplete="off" disabled>
|
||||
<option selected value="">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<span>Lock Cursor to Player:</span>
|
||||
<input type="checkbox" id="lockMouseCheck" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
For more information about sample, see
|
||||
<a href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@3.1/manual/sample-broadcast.html">Broadcast sample</a> document page.
|
||||
</p>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp/client/public/receiver"
|
||||
title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
|
||||
<script src="https://unpkg.com/event-target@latest/min.js"></script>
|
||||
<script src="https://unpkg.com/resize-observer-polyfill@1.5.0/dist/ResizeObserver.global.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
186
WebApp/client/public/receiver/js/main.js
Normal file
186
WebApp/client/public/receiver/js/main.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { getServerConfig, getRTCConfiguration } from "../../js/config.js";
|
||||
import { createDisplayStringArray } from "../../js/stats.js";
|
||||
import { VideoPlayer } from "../../js/videoplayer.js";
|
||||
import { RenderStreaming } from "../../module/renderstreaming.js";
|
||||
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
|
||||
|
||||
/** @type {Element} */
|
||||
let playButton;
|
||||
/** @type {RenderStreaming} */
|
||||
let renderstreaming;
|
||||
/** @type {boolean} */
|
||||
let useWebSocket;
|
||||
|
||||
const codecPreferences = document.getElementById('codecPreferences');
|
||||
const supportsSetCodecPreferences = window.RTCRtpTransceiver &&
|
||||
'setCodecPreferences' in window.RTCRtpTransceiver.prototype;
|
||||
const messageDiv = document.getElementById('message');
|
||||
messageDiv.style.display = 'none';
|
||||
|
||||
const playerDiv = document.getElementById('player');
|
||||
const lockMouseCheck = document.getElementById('lockMouseCheck');
|
||||
const videoPlayer = new VideoPlayer();
|
||||
|
||||
setup();
|
||||
|
||||
window.document.oncontextmenu = function () {
|
||||
return false; // cancel default menu
|
||||
};
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
videoPlayer.resizeVideo();
|
||||
}, true);
|
||||
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
if(!renderstreaming)
|
||||
return;
|
||||
await renderstreaming.stop();
|
||||
}, true);
|
||||
|
||||
async function setup() {
|
||||
const res = await getServerConfig();
|
||||
useWebSocket = res.useWebSocket;
|
||||
showWarningIfNeeded(res.startupMode);
|
||||
showCodecSelect();
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function showWarningIfNeeded(startupMode) {
|
||||
const warningDiv = document.getElementById("warning");
|
||||
if (startupMode == "private") {
|
||||
warningDiv.innerHTML = "<h4>Warning</h4> This sample is not working on Private Mode.";
|
||||
warningDiv.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPlayButton() {
|
||||
if (!document.getElementById('playButton')) {
|
||||
const elementPlayButton = document.createElement('img');
|
||||
elementPlayButton.id = 'playButton';
|
||||
elementPlayButton.src = '../../images/Play.png';
|
||||
elementPlayButton.alt = 'Start Streaming';
|
||||
playButton = document.getElementById('player').appendChild(elementPlayButton);
|
||||
playButton.addEventListener('click', onClickPlayButton);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickPlayButton() {
|
||||
playButton.style.display = 'none';
|
||||
|
||||
// add video player
|
||||
videoPlayer.createPlayer(playerDiv, lockMouseCheck);
|
||||
setupRenderStreaming();
|
||||
}
|
||||
|
||||
async function setupRenderStreaming() {
|
||||
codecPreferences.disabled = true;
|
||||
|
||||
const signaling = useWebSocket ? new WebSocketSignaling() : new Signaling();
|
||||
const config = getRTCConfiguration();
|
||||
renderstreaming = new RenderStreaming(signaling, config);
|
||||
renderstreaming.onConnect = onConnect;
|
||||
renderstreaming.onDisconnect = onDisconnect;
|
||||
renderstreaming.onTrackEvent = (data) => videoPlayer.addTrack(data.track);
|
||||
renderstreaming.onGotOffer = setCodecPreferences;
|
||||
|
||||
await renderstreaming.start();
|
||||
await renderstreaming.createConnection();
|
||||
}
|
||||
|
||||
function onConnect() {
|
||||
const channel = renderstreaming.createDataChannel("input");
|
||||
videoPlayer.setupInput(channel);
|
||||
showStatsMessage();
|
||||
}
|
||||
|
||||
async function onDisconnect(connectionId) {
|
||||
clearStatsMessage();
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerText = `Disconnect peer on ${connectionId}.`;
|
||||
|
||||
await renderstreaming.stop();
|
||||
renderstreaming = null;
|
||||
videoPlayer.deletePlayer();
|
||||
if (supportsSetCodecPreferences) {
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function setCodecPreferences() {
|
||||
/** @type {RTCRtpCodecCapability[] | null} */
|
||||
let selectedCodecs = null;
|
||||
if (supportsSetCodecPreferences) {
|
||||
const preferredCodec = codecPreferences.options[codecPreferences.selectedIndex];
|
||||
if (preferredCodec.value !== '') {
|
||||
const [mimeType, sdpFmtpLine] = preferredCodec.value.split(' ');
|
||||
const { codecs } = RTCRtpSender.getCapabilities('video');
|
||||
const selectedCodecIndex = codecs.findIndex(c => c.mimeType === mimeType && c.sdpFmtpLine === sdpFmtpLine);
|
||||
const selectCodec = codecs[selectedCodecIndex];
|
||||
selectedCodecs = [selectCodec];
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCodecs == null) {
|
||||
return;
|
||||
}
|
||||
const transceivers = renderstreaming.getTransceivers().filter(t => t.receiver.track.kind == "video");
|
||||
if (transceivers && transceivers.length > 0) {
|
||||
transceivers.forEach(t => t.setCodecPreferences(selectedCodecs));
|
||||
}
|
||||
}
|
||||
|
||||
function showCodecSelect() {
|
||||
if (!supportsSetCodecPreferences) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = `Current Browser does not support <a href="https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpTransceiver/setCodecPreferences">RTCRtpTransceiver.setCodecPreferences</a>.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const codecs = RTCRtpSender.getCapabilities('video').codecs;
|
||||
codecs.forEach(codec => {
|
||||
if (['video/red', 'video/ulpfec', 'video/rtx'].includes(codec.mimeType)) {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = (codec.mimeType + ' ' + (codec.sdpFmtpLine || '')).trim();
|
||||
option.innerText = option.value;
|
||||
codecPreferences.appendChild(option);
|
||||
});
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
|
||||
/** @type {RTCStatsReport} */
|
||||
let lastStats;
|
||||
/** @type {number} */
|
||||
let intervalId;
|
||||
|
||||
function showStatsMessage() {
|
||||
intervalId = setInterval(async () => {
|
||||
if (renderstreaming == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await renderstreaming.getStats();
|
||||
if (stats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const array = createDisplayStringArray(stats, lastStats);
|
||||
if (array.length) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = array.join('<br>');
|
||||
}
|
||||
lastStats = stats;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearStatsMessage() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
lastStats = null;
|
||||
intervalId = null;
|
||||
messageDiv.style.display = 'none';
|
||||
messageDiv.innerHTML = '';
|
||||
}
|
||||
103
WebApp/client/public/videoplayer/css/style.css
Normal file
103
WebApp/client/public/videoplayer/css/style.css
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
body {
|
||||
margin: 0px;
|
||||
}
|
||||
button#muteButton {
|
||||
margin: 5px 0;
|
||||
width: auto;
|
||||
}
|
||||
#player {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
background-color: #323232;
|
||||
}
|
||||
|
||||
#player:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 66%;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
width: 15%;
|
||||
max-width: 200px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#Video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#VideoThumbnail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
}
|
||||
|
||||
#greenButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
width: 160px;
|
||||
background-color: #4CAF50;
|
||||
/* Green */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#blueButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 180px;
|
||||
width: 160px;
|
||||
background-color: #447FAF;
|
||||
/* Blue */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#orangeButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 350px;
|
||||
width: 160px;
|
||||
background-color: #FF7700;
|
||||
/* Blue */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#fullscreenButton {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 25px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
BIN
WebApp/client/public/videoplayer/images/FullScreen.png
Normal file
BIN
WebApp/client/public/videoplayer/images/FullScreen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
WebApp/client/public/videoplayer/images/Play.png
Normal file
BIN
WebApp/client/public/videoplayer/images/Play.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 472 KiB |
37
WebApp/client/public/videoplayer/index.html
Normal file
37
WebApp/client/public/videoplayer/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="../images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="../css/main.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<title>VideoPlayer Sample</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="container">
|
||||
<h1>VideoPlayer Sample</h1>
|
||||
|
||||
<div id="warning" hidden=true></div>
|
||||
|
||||
<div id="player"></div>
|
||||
|
||||
<p>For more information about <code>WebBrowserInput</code> sample, see <a href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@latest/sample-browserinput.html">WebBrowserInput
|
||||
sample</a> document page.</p>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp/public/videoplayer"
|
||||
title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
|
||||
<script src="https://unpkg.com/event-target@latest/min.js"></script>
|
||||
<script src="https://unpkg.com/resize-observer-polyfill@1.5.0/dist/ResizeObserver.global.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
146
WebApp/client/public/videoplayer/js/gamepadEvents.js
Normal file
146
WebApp/client/public/videoplayer/js/gamepadEvents.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as Logger from "../../module/logger.js";
|
||||
|
||||
const _e = 0.09;
|
||||
const _gameloopInterval = 16.67; //in milliseconds, 60 times a second
|
||||
var gameloop = null;
|
||||
var gamepadsPreviousButtonsStates = {};
|
||||
var gamepadsPreviousAxesStates = {};
|
||||
var gamepadsConnectedTimeStamp = {};
|
||||
const _axisOffset = 100;
|
||||
const _axisMultiplier = 1;
|
||||
const _axisYInverted = -1;
|
||||
|
||||
class GamepadButtonEvent extends Event {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.index = arguments[1].index;
|
||||
this.id = arguments[1].id;
|
||||
this.value = arguments[1].value;
|
||||
}
|
||||
}
|
||||
|
||||
class GamepadAxisEvent extends Event {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.index = arguments[1].index;
|
||||
this.x = arguments[1].x;
|
||||
this.y = arguments[1].y;
|
||||
this.id = arguments[1].id;
|
||||
}
|
||||
}
|
||||
|
||||
function storePreviousState(gamepad) {
|
||||
gamepadsPreviousButtonsStates[gamepad.index] = {};
|
||||
gamepad.buttons.forEach(function (button, index) {
|
||||
gamepadsPreviousButtonsStates[gamepad.index][index] = { value: button.value, pressed: button.pressed };
|
||||
});
|
||||
|
||||
gamepadsPreviousAxesStates[gamepad.index] = [gamepad.axes.length];
|
||||
for (var index = 0; index < gamepad.axes.length; index++)
|
||||
gamepadsPreviousAxesStates[gamepad.index][index] = gamepad.axes[index];
|
||||
}
|
||||
|
||||
function checkAxes(gamepad, previousGamePad) {
|
||||
for (var i = 0; i < gamepad.axes.length; i += 2) {
|
||||
var absX = Math.abs(gamepad.axes[i]);
|
||||
var absY = Math.abs(gamepad.axes[i + 1]);
|
||||
var event = null;
|
||||
if ((absX > _e) ||
|
||||
(absY > _e)) {
|
||||
|
||||
event = new GamepadAxisEvent('gamepadAxis', { id: gamepadsConnectedTimeStamp[gamepad.index], index: i / 2 + _axisOffset, x: gamepad.axes[i] * _axisMultiplier, y: gamepad.axes[i + 1] * _axisMultiplier * _axisYInverted });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
else {
|
||||
var previousAbsX = Math.abs(previousGamePad[i]);
|
||||
var previousAbsY = Math.abs(previousGamePad[i + 1]);
|
||||
|
||||
//have to send if previously was moved
|
||||
if ((previousAbsX > _e) ||
|
||||
(previousAbsY > _e)) {
|
||||
event = new GamepadAxisEvent('gamepadAxis', { id: gamepadsConnectedTimeStamp[gamepad.index], index: i / 2 + _axisOffset, x: 0.0, y: 0.0 });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
Object.keys(gamepadsPreviousAxesStates).forEach(function (gamepadIndex) {
|
||||
var gamepad = navigator.webkitGetGamepads ? navigator.webkitGetGamepads()[gamepadIndex] : navigator.getGamepads()[gamepadIndex];
|
||||
var previousButtons = gamepadsPreviousButtonsStates[gamepadIndex];
|
||||
gamepad.buttons.forEach(function (button, index) {
|
||||
var buttonStatus = navigator.webkitGetGamepads ? button == 1 : (button.value > 0 || button.pressed == true);
|
||||
var previousButtonStatus = navigator.webkitGetGamepads ? previousButtons[index].value == 1 : (previousButtons[index].value > 0 || previousButtons[index].pressed == true);
|
||||
var event;
|
||||
if (buttonStatus != previousButtonStatus) {
|
||||
if (buttonStatus) {
|
||||
event = new GamepadButtonEvent('gamepadButtonDown', { id: gamepadsConnectedTimeStamp[gamepad.index], index: index, value: button.value });
|
||||
}
|
||||
else {
|
||||
event = new GamepadButtonEvent('gamepadButtonUp', { id: gamepadsConnectedTimeStamp[gamepad.index], index: index, value: 0 });
|
||||
}
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
else if (buttonStatus) {
|
||||
event = new GamepadButtonEvent('gamepadButtonPressed', { id: gamepadsConnectedTimeStamp[gamepad.index], index: index, value: button.value });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
});
|
||||
checkAxes(gamepad, gamepadsPreviousAxesStates[gamepadIndex]);
|
||||
storePreviousState(gamepad);
|
||||
});
|
||||
}
|
||||
|
||||
function getCookie(cname) {
|
||||
var name = cname + "=";
|
||||
var decodedCookie = decodeURIComponent(document.cookie);
|
||||
var ca = decodedCookie.split(';');
|
||||
for (var i = 0; i < ca.length; i++) {
|
||||
var c = ca[i];
|
||||
while (c.charAt(0) == ' ') {
|
||||
c = c.substring(1);
|
||||
}
|
||||
if (c.indexOf(name) == 0) {
|
||||
return c.substring(name.length, c.length);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function gamepadHandler(event, connecting) {
|
||||
var gamepad = event.gamepad;
|
||||
|
||||
var key = gamepad.id.replace(/\s/g, '');
|
||||
var cookieTimeStamp = getCookie(key);
|
||||
|
||||
if (connecting) {
|
||||
storePreviousState(gamepad);
|
||||
if (Object.keys(gamepadsPreviousAxesStates).length == 1) {
|
||||
gameloop = setInterval(gameLoop, _gameloopInterval);
|
||||
}
|
||||
|
||||
//try to find the timestamp
|
||||
//need to strip the : from the id
|
||||
|
||||
if (cookieTimeStamp == "") {
|
||||
document.cookie = key + "=" + gamepad.timestamp;
|
||||
gamepadsConnectedTimeStamp[gamepad.index] = gamepad.timestamp;
|
||||
}
|
||||
else {
|
||||
gamepadsConnectedTimeStamp[gamepad.index] = cookieTimeStamp;
|
||||
}
|
||||
|
||||
Logger.log("connected: " + gamepadsConnectedTimeStamp[gamepad.index]);
|
||||
|
||||
} else {
|
||||
delete gamepadsPreviousAxesStates[gamepad.index];
|
||||
delete gamepadsPreviousButtonsStates[gamepad.index];
|
||||
if (Object.keys(gamepadsPreviousAxesStates).length == 0) {
|
||||
clearInterval(gameloop);
|
||||
gameloop = null;
|
||||
}
|
||||
Logger.log("disconnected: " + gamepad.id);
|
||||
}
|
||||
}
|
||||
156
WebApp/client/public/videoplayer/js/main.js
Normal file
156
WebApp/client/public/videoplayer/js/main.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import { VideoPlayer } from "./video-player.js";
|
||||
import { registerGamepadEvents, registerKeyboardEvents, registerMouseEvents, sendClickEvent } from "./register-events.js";
|
||||
import { getServerConfig } from "../../js/config.js";
|
||||
|
||||
setup();
|
||||
|
||||
let playButton;
|
||||
let videoPlayer;
|
||||
let useWebSocket;
|
||||
|
||||
window.document.oncontextmenu = function () {
|
||||
return false; // cancel default menu
|
||||
};
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
videoPlayer.resizeVideo();
|
||||
}, true);
|
||||
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
await videoPlayer.stop();
|
||||
}, true);
|
||||
|
||||
async function setup() {
|
||||
const res = await getServerConfig();
|
||||
useWebSocket = res.useWebSocket;
|
||||
showWarningIfNeeded(res.startupMode);
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function showWarningIfNeeded(startupMode) {
|
||||
const warningDiv = document.getElementById("warning");
|
||||
if (startupMode == "private") {
|
||||
warningDiv.innerHTML = "<h4>Warning</h4> This sample is not working on Private Mode.";
|
||||
warningDiv.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPlayButton() {
|
||||
if (!document.getElementById('playButton')) {
|
||||
let elementPlayButton = document.createElement('img');
|
||||
elementPlayButton.id = 'playButton';
|
||||
elementPlayButton.src = 'images/Play.png';
|
||||
elementPlayButton.alt = 'Start Streaming';
|
||||
playButton = document.getElementById('player').appendChild(elementPlayButton);
|
||||
playButton.addEventListener('click', onClickPlayButton);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickPlayButton() {
|
||||
|
||||
playButton.style.display = 'none';
|
||||
|
||||
const playerDiv = document.getElementById('player');
|
||||
|
||||
// add video player
|
||||
const elementVideo = document.createElement('video');
|
||||
elementVideo.id = 'Video';
|
||||
elementVideo.style.touchAction = 'none';
|
||||
playerDiv.appendChild(elementVideo);
|
||||
|
||||
// add video thumbnail
|
||||
const elementVideoThumb = document.createElement('video');
|
||||
elementVideoThumb.id = 'VideoThumbnail';
|
||||
elementVideoThumb.style.touchAction = 'none';
|
||||
playerDiv.appendChild(elementVideoThumb);
|
||||
|
||||
setupVideoPlayer([elementVideo, elementVideoThumb]).then(value => videoPlayer = value);
|
||||
|
||||
// add blue button
|
||||
const elementBlueButton = document.createElement('button');
|
||||
elementBlueButton.id = "blueButton";
|
||||
elementBlueButton.innerHTML = "Light on";
|
||||
playerDiv.appendChild(elementBlueButton);
|
||||
elementBlueButton.addEventListener("click", function () {
|
||||
sendClickEvent(videoPlayer, 1);
|
||||
});
|
||||
|
||||
// add green button
|
||||
const elementGreenButton = document.createElement('button');
|
||||
elementGreenButton.id = "greenButton";
|
||||
elementGreenButton.innerHTML = "Light off";
|
||||
playerDiv.appendChild(elementGreenButton);
|
||||
elementGreenButton.addEventListener("click", function () {
|
||||
sendClickEvent(videoPlayer, 2);
|
||||
});
|
||||
|
||||
// add orange button
|
||||
const elementOrangeButton = document.createElement('button');
|
||||
elementOrangeButton.id = "orangeButton";
|
||||
elementOrangeButton.innerHTML = "Play audio";
|
||||
playerDiv.appendChild(elementOrangeButton);
|
||||
elementOrangeButton.addEventListener("click", function () {
|
||||
sendClickEvent(videoPlayer, 3);
|
||||
});
|
||||
|
||||
// add fullscreen button
|
||||
const elementFullscreenButton = document.createElement('img');
|
||||
elementFullscreenButton.id = 'fullscreenButton';
|
||||
elementFullscreenButton.src = 'images/FullScreen.png';
|
||||
playerDiv.appendChild(elementFullscreenButton);
|
||||
elementFullscreenButton.addEventListener("click", function () {
|
||||
if (!document.fullscreenElement || !document.webkitFullscreenElement) {
|
||||
if (document.documentElement.requestFullscreen) {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
else if (document.documentElement.webkitRequestFullscreen) {
|
||||
document.documentElement.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
|
||||
} else {
|
||||
if (playerDiv.style.position == "absolute") {
|
||||
playerDiv.style.position = "relative";
|
||||
} else {
|
||||
playerDiv.style.position = "absolute";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||
|
||||
function onFullscreenChange() {
|
||||
if (document.webkitFullscreenElement || document.fullscreenElement) {
|
||||
playerDiv.style.position = "absolute";
|
||||
elementFullscreenButton.style.display = 'none';
|
||||
}
|
||||
else {
|
||||
playerDiv.style.position = "relative";
|
||||
elementFullscreenButton.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupVideoPlayer(elements) {
|
||||
const videoPlayer = new VideoPlayer(elements);
|
||||
await videoPlayer.setupConnection(useWebSocket);
|
||||
|
||||
videoPlayer.ondisconnect = onDisconnect;
|
||||
registerGamepadEvents(videoPlayer);
|
||||
registerKeyboardEvents(videoPlayer);
|
||||
registerMouseEvents(videoPlayer, elements[0]);
|
||||
|
||||
return videoPlayer;
|
||||
}
|
||||
|
||||
function onDisconnect() {
|
||||
const playerDiv = document.getElementById('player');
|
||||
clearChildren(playerDiv);
|
||||
videoPlayer.stop();
|
||||
videoPlayer = null;
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function clearChildren(element) {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}
|
||||
307
WebApp/client/public/videoplayer/js/register-events.js
Normal file
307
WebApp/client/public/videoplayer/js/register-events.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import { gamepadHandler } from "./gamepadEvents.js";
|
||||
import * as Logger from "../../module/logger.js";
|
||||
import { Keymap } from "../../module/keymap.js";
|
||||
|
||||
const InputEvent = {
|
||||
Keyboard: 0,
|
||||
Mouse: 1,
|
||||
MouseWheel: 2,
|
||||
Touch: 3,
|
||||
ButtonClick: 4,
|
||||
Gamepad: 5
|
||||
};
|
||||
|
||||
const KeyboardEventType = {
|
||||
Up: 0,
|
||||
Down: 1
|
||||
};
|
||||
|
||||
const GamepadEventType = {
|
||||
ButtonUp: 0,
|
||||
ButtonDown: 1,
|
||||
ButtonPressed: 2,
|
||||
Axis: 3
|
||||
};
|
||||
|
||||
const PointerPhase = {
|
||||
None: 0,
|
||||
Began: 1,
|
||||
Moved: 2,
|
||||
Ended: 3,
|
||||
Canceled: 4,
|
||||
Stationary: 5
|
||||
};
|
||||
|
||||
let sendGamepadButtonDown = undefined;
|
||||
let sendGamepadButtonUp = undefined;
|
||||
let sendGamepadButtonPressed;
|
||||
let gamepadAxisChange = undefined;
|
||||
let gamepadConnected = undefined;
|
||||
let gamepadDisconnected = undefined;
|
||||
|
||||
export function registerGamepadEvents(videoPlayer) {
|
||||
|
||||
const _videoPlayer = videoPlayer;
|
||||
|
||||
sendGamepadButtonDown = (e) => {
|
||||
Logger.log("gamepad id: " + e.id + " button index: " + e.index + " value " + e.value + " down");
|
||||
let data = new DataView(new ArrayBuffer(19));
|
||||
data.setUint8(0, InputEvent.Gamepad);
|
||||
data.setUint8(1, GamepadEventType.ButtonDown);
|
||||
data.setUint8(2, e.index);
|
||||
data.setFloat64(3, e.value, true);
|
||||
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
sendGamepadButtonUp = (e) => {
|
||||
Logger.log("gamepad id: " + e.id + " button index: " + e.index + " value " + e.value + " up");
|
||||
let data = new DataView(new ArrayBuffer(19));
|
||||
data.setUint8(0, InputEvent.Gamepad);
|
||||
data.setUint8(1, GamepadEventType.ButtonUp);
|
||||
data.setUint8(2, e.index);
|
||||
data.setFloat64(3, e.value, true);
|
||||
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
sendGamepadButtonPressed = (e) => {
|
||||
Logger.log("gamepad id: " + e.id + " button index: " + e.index + " value " + e.value + " pressed");
|
||||
let data = new DataView(new ArrayBuffer(19));
|
||||
data.setUint8(0, InputEvent.Gamepad);
|
||||
data.setUint8(1, GamepadEventType.ButtonPressed);
|
||||
data.setUint8(2, e.index);
|
||||
data.setFloat64(3, e.value, true);
|
||||
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
gamepadAxisChange = (e) => {
|
||||
Logger.log("gamepad id: " + e.id + " axis: " + e.index + " value " + e.value + " x:" + e.x + " y:" + e.y);
|
||||
let data = new DataView(new ArrayBuffer(27));
|
||||
data.setUint8(0, InputEvent.Gamepad);
|
||||
data.setUint8(1, GamepadEventType.Axis);
|
||||
data.setUint8(2, e.index);
|
||||
data.setFloat64(3, e.x, true);
|
||||
data.setFloat64(11, e.y, true);
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
gamepadConnected = (e) => { gamepadHandler(e, true); };
|
||||
gamepadDisconnected = (e) => { gamepadHandler(e, false); };
|
||||
|
||||
|
||||
document.addEventListener("gamepadButtonDown", sendGamepadButtonDown, false);
|
||||
document.addEventListener("gamepadButtonUp", sendGamepadButtonUp, false);
|
||||
document.addEventListener("gamepadButtonPressed", sendGamepadButtonPressed, false);
|
||||
document.addEventListener("gamepadAxis", gamepadAxisChange, false);
|
||||
|
||||
window.addEventListener("gamepadconnected", gamepadConnected, false);
|
||||
window.addEventListener("gamepaddisconnected", gamepadDisconnected, false);
|
||||
}
|
||||
|
||||
export function unregisterGamepadEvents() {
|
||||
|
||||
document.removeEventListener("gamepadButtonDown", sendGamepadButtonDown, false);
|
||||
document.removeEventListener("gamepadButtonUp", sendGamepadButtonUp, false);
|
||||
document.removeEventListener("gamepadButtonPressed", sendGamepadButtonPressed, false);
|
||||
document.removeEventListener("gamepadAxis", gamepadAxisChange, false);
|
||||
|
||||
window.removeEventListener("gamepadconnected", gamepadConnected, false);
|
||||
window.removeEventListener("gamepaddisconnected", gamepadDisconnected, false);
|
||||
|
||||
}
|
||||
|
||||
|
||||
let sendKeyUp = undefined;
|
||||
let sendKeyDown = undefined;
|
||||
|
||||
|
||||
export function registerKeyboardEvents(videoPlayer) {
|
||||
|
||||
const _videoPlayer = videoPlayer;
|
||||
|
||||
function sendKey(e, type) {
|
||||
const key = Keymap[e.code];
|
||||
const character = e.key.length === 1 ? e.key.charCodeAt(0) : 0;
|
||||
Logger.log("key down " + key + ", repeat = " + e.repeat + ", character = " + character);
|
||||
_videoPlayer && _videoPlayer.sendMsg(new Uint8Array([InputEvent.Keyboard, type, e.repeat, key, character]).buffer);
|
||||
}
|
||||
|
||||
|
||||
sendKeyUp = (e) => {
|
||||
sendKey(e, KeyboardEventType.Up);
|
||||
};
|
||||
|
||||
sendKeyDown = (e) => {
|
||||
sendKey(e, KeyboardEventType.Down);
|
||||
};
|
||||
|
||||
document.addEventListener('keyup', sendKeyUp, false);
|
||||
document.addEventListener('keydown', sendKeyDown, false);
|
||||
}
|
||||
|
||||
|
||||
export function unregisterKeyboardEvents() {
|
||||
|
||||
//Stop listening to keyboard events
|
||||
document.removeEventListener('keyup', sendKeyUp, false);
|
||||
document.removeEventListener('keydown', sendKeyDown, false);
|
||||
}
|
||||
|
||||
|
||||
let sendMouse = undefined;
|
||||
let sendMouseWheel = undefined;
|
||||
let sendTouchEnd = undefined;
|
||||
let sendTouchStart = undefined;
|
||||
let sendTouchCancel = undefined;
|
||||
let sendTouchMove = undefined;
|
||||
|
||||
|
||||
export function registerMouseEvents(videoPlayer, playerElement) {
|
||||
|
||||
const _videoPlayer = videoPlayer;
|
||||
|
||||
function sendTouch(e, phase) {
|
||||
const changedTouches = Array.from(e.changedTouches);
|
||||
const touches = Array.from(e.touches);
|
||||
const phrases = [];
|
||||
|
||||
for (let i = 0; i < changedTouches.length; i++) {
|
||||
if (touches.find(function (t) {
|
||||
return t.identifier === changedTouches[i].identifier;
|
||||
}) === undefined) {
|
||||
touches.push(changedTouches[i]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < touches.length; i++) {
|
||||
touches[i].identifier;
|
||||
phrases[i] = changedTouches.find(
|
||||
function (e) {
|
||||
return e.identifier === touches[i].identifier;
|
||||
}) === undefined ? PointerPhase.Stationary : phase;
|
||||
}
|
||||
|
||||
Logger.log("touch phase:" + phase + " length:" + changedTouches.length + " pageX" + changedTouches[0].pageX + ", pageX: " + changedTouches[0].pageY + ", force:" + changedTouches[0].force);
|
||||
|
||||
let data = new DataView(new ArrayBuffer(2 + 13 * touches.length));
|
||||
data.setUint8(0, InputEvent.Touch);
|
||||
data.setUint8(1, touches.length);
|
||||
let byteOffset = 2;
|
||||
for (let i = 0; i < touches.length; i++) {
|
||||
|
||||
const scale = _videoPlayer.videoScale;
|
||||
const originX = _videoPlayer.videoOriginX;
|
||||
const originY = _videoPlayer.videoOriginY;
|
||||
|
||||
const x = (touches[i].pageX - originX) / scale;
|
||||
// According to Unity Coordinate system
|
||||
// const y = (touches[i].pageX - originY) / scale;
|
||||
const y = _videoPlayer.videoHeight - (touches[i].pageY - originY) / scale;
|
||||
|
||||
data.setInt32(byteOffset, touches[i].identifier, true);
|
||||
byteOffset += 4;
|
||||
data.setUint8(byteOffset, phrases[i]);
|
||||
byteOffset += 1;
|
||||
data.setInt16(byteOffset, x, true);
|
||||
byteOffset += 2;
|
||||
data.setInt16(byteOffset, y, true);
|
||||
byteOffset += 2;
|
||||
data.setFloat32(byteOffset, touches[i].force, true);
|
||||
byteOffset += 4;
|
||||
}
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
}
|
||||
|
||||
sendTouchMove = (e) => {
|
||||
sendTouch(e, PointerPhase.Moved);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
sendTouchStart = (e) => {
|
||||
sendTouch(e, PointerPhase.Began);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
sendTouchEnd = (e) => {
|
||||
sendTouch(e, PointerPhase.Ended);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
sendTouchCancel = (e) => {
|
||||
sendTouch(e, PointerPhase.Canceled);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
sendMouse = (e) => {
|
||||
const scale = _videoPlayer.videoScale;
|
||||
const originX = _videoPlayer.videoOriginX;
|
||||
const originY = _videoPlayer.videoOriginY;
|
||||
|
||||
const x = (e.clientX - originX) / scale;
|
||||
// According to Unity Coordinate system
|
||||
// const y = (e.clientY - originY) / scale;
|
||||
const y = _videoPlayer.videoHeight - (e.clientY - originY) / scale;
|
||||
|
||||
Logger.log("x: " + x + ", y: " + y + ", scale: " + scale + ", originX: " + originX + ", originY: " + originY + " mouse button:" + e.buttons);
|
||||
let data = new DataView(new ArrayBuffer(6));
|
||||
data.setUint8(0, InputEvent.Mouse);
|
||||
data.setInt16(1, x, true);
|
||||
data.setInt16(3, y, true);
|
||||
data.setUint8(5, e.buttons);
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
function sendMouseWheel(e) {
|
||||
Logger.log("mouse wheel with delta " + e.wheelDelta);
|
||||
let data = new DataView(new ArrayBuffer(9));
|
||||
data.setUint8(0, InputEvent.MouseWheel);
|
||||
data.setFloat32(1, e.deltaX, true);
|
||||
data.setFloat32(5, e.deltaY, true);
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
}
|
||||
|
||||
// Listen to mouse events
|
||||
playerElement.addEventListener('click', sendMouse, false);
|
||||
playerElement.addEventListener('mousedown', sendMouse, false);
|
||||
playerElement.addEventListener('mouseup', sendMouse, false);
|
||||
playerElement.addEventListener('mousemove', sendMouse, false);
|
||||
playerElement.addEventListener('wheel', sendMouseWheel, false);
|
||||
|
||||
// Listen to touch events based on "Touch Events Level1" TR.
|
||||
//
|
||||
// Touch event Level1 https://www.w3.org/TR/touch-events/
|
||||
// Touch event Level2 https://w3c.github.io/touch-events/
|
||||
//
|
||||
playerElement.addEventListener('touchend', sendTouchEnd, false);
|
||||
playerElement.addEventListener('touchstart', sendTouchStart, false);
|
||||
playerElement.addEventListener('touchcancel', sendTouchCancel, false);
|
||||
playerElement.addEventListener('touchmove', sendTouchMove, false);
|
||||
}
|
||||
|
||||
|
||||
export function unregisterMouseEvents(playerElement) {
|
||||
|
||||
// Stop listening to mouse events
|
||||
playerElement.removeEventListener('click', sendMouse, false);
|
||||
playerElement.removeEventListener('mousedown', sendMouse, false);
|
||||
playerElement.removeEventListener('mouseup', sendMouse, false);
|
||||
playerElement.removeEventListener('mousemove', sendMouse, false);
|
||||
playerElement.removeEventListener('wheel', sendMouseWheel, false);
|
||||
|
||||
// Stop listening to touch events based on "Touch Events Level1" TR.
|
||||
playerElement.removeEventListener('touchend', sendTouchEnd, false);
|
||||
playerElement.removeEventListener('touchstart', sendTouchStart, false);
|
||||
playerElement.removeEventListener('touchcancel', sendTouchCancel, false);
|
||||
playerElement.removeEventListener('touchmove', sendTouchMove, false);
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function sendClickEvent(videoPlayer, elementId) {
|
||||
let data = new DataView(new ArrayBuffer(3));
|
||||
data.setUint8(0, InputEvent.ButtonClick);
|
||||
data.setInt16(1, elementId, true);
|
||||
videoPlayer && videoPlayer.sendMsg(data.buffer);
|
||||
}
|
||||
246
WebApp/client/public/videoplayer/js/video-player.js
Normal file
246
WebApp/client/public/videoplayer/js/video-player.js
Normal file
@@ -0,0 +1,246 @@
|
||||
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
|
||||
import Peer from "../../module/peer.js";
|
||||
import * as Logger from "../../module/logger.js";
|
||||
|
||||
|
||||
// enum type of event sending from Unity
|
||||
var UnityEventType = {
|
||||
SWITCH_VIDEO: 0
|
||||
};
|
||||
|
||||
function uuid4() {
|
||||
var temp_url = URL.createObjectURL(new Blob());
|
||||
var uuid = temp_url.toString();
|
||||
URL.revokeObjectURL(temp_url);
|
||||
return uuid.split(/[:/]/g).pop().toLowerCase(); // remove prefixes
|
||||
}
|
||||
|
||||
export class VideoPlayer {
|
||||
constructor(elements) {
|
||||
const _this = this;
|
||||
this.pc = null;
|
||||
this.channel = null;
|
||||
this.connectionId = null;
|
||||
|
||||
// main video
|
||||
this.localStream = new MediaStream();
|
||||
this.video = elements[0];
|
||||
this.video.playsInline = true;
|
||||
this.video.addEventListener('loadedmetadata', function () {
|
||||
_this.video.play();
|
||||
_this.resizeVideo();
|
||||
}, true);
|
||||
|
||||
// secondly video
|
||||
this.localStream2 = new MediaStream();
|
||||
this.videoThumb = elements[1];
|
||||
this.videoThumb.playsInline = true;
|
||||
this.videoThumb.addEventListener('loadedmetadata', function () {
|
||||
_this.videoThumb.play();
|
||||
}, true);
|
||||
|
||||
this.videoTrackList = [];
|
||||
this.videoTrackIndex = 0;
|
||||
this.maxVideoTrackLength = 2;
|
||||
|
||||
this.ondisconnect = function () { };
|
||||
}
|
||||
|
||||
async setupConnection(useWebSocket) {
|
||||
const _this = this;
|
||||
// close current RTCPeerConnection
|
||||
if (this.pc) {
|
||||
Logger.log('Close current PeerConnection');
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
if (useWebSocket) {
|
||||
this.signaling = new WebSocketSignaling();
|
||||
} else {
|
||||
this.signaling = new Signaling();
|
||||
}
|
||||
|
||||
this.connectionId = uuid4();
|
||||
|
||||
// Create peerConnection with proxy server and set up handlers
|
||||
this.pc = new Peer(this.connectionId, true);
|
||||
this.pc.addEventListener('disconnect', () => {
|
||||
_this.ondisconnect();
|
||||
});
|
||||
this.pc.addEventListener('trackevent', (e) => {
|
||||
const data = e.detail;
|
||||
if (data.track.kind == 'video') {
|
||||
_this.videoTrackList.push(data.track);
|
||||
}
|
||||
if (data.track.kind == 'audio') {
|
||||
_this.localStream.addTrack(data.track);
|
||||
}
|
||||
if (_this.videoTrackList.length == _this.maxVideoTrackLength) {
|
||||
_this.switchVideo(_this.videoTrackIndex);
|
||||
}
|
||||
});
|
||||
this.pc.addEventListener('sendoffer', (e) => {
|
||||
const offer = e.detail;
|
||||
_this.signaling.sendOffer(offer.connectionId, offer.sdp);
|
||||
});
|
||||
this.pc.addEventListener('sendanswer', (e) => {
|
||||
const answer = e.detail;
|
||||
_this.signaling.sendAnswer(answer.connectionId, answer.sdp);
|
||||
});
|
||||
this.pc.addEventListener('sendcandidate', (e) => {
|
||||
const candidate = e.detail;
|
||||
_this.signaling.sendCandidate(candidate.connectionId, candidate.candidate, candidate.sdpMid, candidate.sdpMLineIndex);
|
||||
});
|
||||
|
||||
this.signaling.addEventListener('disconnect', async (e) => {
|
||||
const data = e.detail;
|
||||
if (_this.pc != null && _this.pc.connectionId == data.connectionId) {
|
||||
_this.ondisconnect();
|
||||
}
|
||||
});
|
||||
this.signaling.addEventListener('offer', async (e) => {
|
||||
const offer = e.detail;
|
||||
const desc = new RTCSessionDescription({ sdp: offer.sdp, type: "offer" });
|
||||
if (_this.pc != null) {
|
||||
await _this.pc.onGotDescription(offer.connectionId, desc);
|
||||
}
|
||||
});
|
||||
this.signaling.addEventListener('answer', async (e) => {
|
||||
const answer = e.detail;
|
||||
const desc = new RTCSessionDescription({ sdp: answer.sdp, type: "answer" });
|
||||
if (_this.pc != null) {
|
||||
await _this.pc.onGotDescription(answer.connectionId, desc);
|
||||
}
|
||||
});
|
||||
this.signaling.addEventListener('candidate', async (e) => {
|
||||
const candidate = e.detail;
|
||||
const iceCandidate = new RTCIceCandidate({ candidate: candidate.candidate, sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex });
|
||||
if (_this.pc != null) {
|
||||
await _this.pc.onGotCandidate(candidate.connectionId, iceCandidate);
|
||||
}
|
||||
});
|
||||
|
||||
// setup signaling
|
||||
await this.signaling.start();
|
||||
|
||||
// Create data channel with proxy server and set up handlers
|
||||
this.channel = this.pc.createDataChannel(this.connectionId, 'data');
|
||||
this.channel.onopen = function () {
|
||||
Logger.log('Datachannel connected.');
|
||||
};
|
||||
this.channel.onerror = function (e) {
|
||||
Logger.log("The error " + e.error.message + " occurred\n while handling data with proxy server.");
|
||||
};
|
||||
this.channel.onclose = function () {
|
||||
Logger.log('Datachannel disconnected.');
|
||||
};
|
||||
this.channel.onmessage = async (msg) => {
|
||||
// receive message from unity and operate message
|
||||
let data;
|
||||
// receive message data type is blob only on Firefox
|
||||
if (navigator.userAgent.indexOf('Firefox') != -1) {
|
||||
data = await msg.data.arrayBuffer();
|
||||
} else {
|
||||
data = msg.data;
|
||||
}
|
||||
const bytes = new Uint8Array(data);
|
||||
_this.videoTrackIndex = bytes[1];
|
||||
switch (bytes[0]) {
|
||||
case UnityEventType.SWITCH_VIDEO:
|
||||
_this.switchVideo(_this.videoTrackIndex);
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
resizeVideo() {
|
||||
const clientRect = this.video.getBoundingClientRect();
|
||||
const videoRatio = this.videoWidth / this.videoHeight;
|
||||
const clientRatio = clientRect.width / clientRect.height;
|
||||
|
||||
this._videoScale = videoRatio > clientRatio ? clientRect.width / this.videoWidth : clientRect.height / this.videoHeight;
|
||||
const videoOffsetX = videoRatio > clientRatio ? 0 : (clientRect.width - this.videoWidth * this._videoScale) * 0.5;
|
||||
const videoOffsetY = videoRatio > clientRatio ? (clientRect.height - this.videoHeight * this._videoScale) * 0.5 : 0;
|
||||
this._videoOriginX = clientRect.left + videoOffsetX;
|
||||
this._videoOriginY = clientRect.top + videoOffsetY;
|
||||
}
|
||||
|
||||
// switch streaming destination main video and secondly video
|
||||
switchVideo(indexVideoTrack) {
|
||||
this.video.srcObject = this.localStream;
|
||||
this.videoThumb.srcObject = this.localStream2;
|
||||
|
||||
if (indexVideoTrack == 0) {
|
||||
this.replaceTrack(this.localStream, this.videoTrackList[0]);
|
||||
this.replaceTrack(this.localStream2, this.videoTrackList[1]);
|
||||
}
|
||||
else {
|
||||
this.replaceTrack(this.localStream, this.videoTrackList[1]);
|
||||
this.replaceTrack(this.localStream2, this.videoTrackList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// replace video track related the MediaStream
|
||||
replaceTrack(stream, newTrack) {
|
||||
const tracks = stream.getVideoTracks();
|
||||
for (const track of tracks) {
|
||||
if (track.kind == 'video') {
|
||||
stream.removeTrack(track);
|
||||
}
|
||||
}
|
||||
stream.addTrack(newTrack);
|
||||
}
|
||||
|
||||
get videoWidth() {
|
||||
return this.video.videoWidth;
|
||||
}
|
||||
|
||||
get videoHeight() {
|
||||
return this.video.videoHeight;
|
||||
}
|
||||
|
||||
get videoOriginX() {
|
||||
return this._videoOriginX;
|
||||
}
|
||||
|
||||
get videoOriginY() {
|
||||
return this._videoOriginY;
|
||||
}
|
||||
|
||||
get videoScale() {
|
||||
return this._videoScale;
|
||||
}
|
||||
|
||||
sendMsg(msg) {
|
||||
if (this.channel == null) {
|
||||
return;
|
||||
}
|
||||
switch (this.channel.readyState) {
|
||||
case 'connecting':
|
||||
Logger.log('Connection not ready');
|
||||
break;
|
||||
case 'open':
|
||||
this.channel.send(msg);
|
||||
break;
|
||||
case 'closing':
|
||||
Logger.log('Attempt to sendMsg message while closing');
|
||||
break;
|
||||
case 'closed':
|
||||
Logger.log('Attempt to sendMsg message while connection closed.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.signaling) {
|
||||
await this.signaling.stop();
|
||||
this.signaling = null;
|
||||
}
|
||||
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
109
WebApp/client/src/charnumber.js
Normal file
109
WebApp/client/src/charnumber.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// KeyboardEvent.charcode is already deprecated.
|
||||
//
|
||||
export const CharNumber = {
|
||||
"Backspace": 8,
|
||||
"Tab": 9,
|
||||
"Enter": 13,
|
||||
"Shift": 16,
|
||||
"Control": 17,
|
||||
"Alt": 18,
|
||||
"Pause": 19,
|
||||
"CapsLock": 20,
|
||||
"Escape": 27,
|
||||
" ": 32,
|
||||
"!": 33,
|
||||
"\"": 34,
|
||||
"#": 35,
|
||||
"$": 36,
|
||||
"%": 37,
|
||||
"&": 38,
|
||||
"'": 39,
|
||||
"(": 40,
|
||||
")": 41,
|
||||
"*": 42,
|
||||
"+": 43,
|
||||
",": 44,
|
||||
"-": 45,
|
||||
".": 46,
|
||||
"/": 47,
|
||||
"0": 48,
|
||||
"1": 49,
|
||||
"2": 50,
|
||||
"3": 51,
|
||||
"4": 52,
|
||||
"5": 53,
|
||||
"6": 54,
|
||||
"7": 55,
|
||||
"8": 56,
|
||||
"9": 57,
|
||||
":": 58,
|
||||
";": 59,
|
||||
"<": 60,
|
||||
"=": 61,
|
||||
">": 62,
|
||||
"?": 63,
|
||||
"@": 64,
|
||||
"A": 65,
|
||||
"B": 66,
|
||||
"C": 67,
|
||||
"D": 68,
|
||||
"E": 69,
|
||||
"F": 70,
|
||||
"G": 71,
|
||||
"H": 72,
|
||||
"I": 73,
|
||||
"J": 74,
|
||||
"K": 75,
|
||||
"L": 76,
|
||||
"M": 77,
|
||||
"N": 78,
|
||||
"O": 79,
|
||||
"P": 80,
|
||||
"Q": 81,
|
||||
"R": 82,
|
||||
"S": 83,
|
||||
"T": 84,
|
||||
"U": 85,
|
||||
"V": 86,
|
||||
"W": 87,
|
||||
"X": 88,
|
||||
"Y": 89,
|
||||
"Z": 90,
|
||||
"[": 91,
|
||||
"\\": 92,
|
||||
"]": 93,
|
||||
"^": 94,
|
||||
"_": 95,
|
||||
"`": 96,
|
||||
"a": 97,
|
||||
"b": 98,
|
||||
"c": 99,
|
||||
"d": 100,
|
||||
"e": 101,
|
||||
"f": 102,
|
||||
"g": 103,
|
||||
"h": 104,
|
||||
"i": 105,
|
||||
"j": 106,
|
||||
"k": 107,
|
||||
"l": 108,
|
||||
"m": 109,
|
||||
"n": 110,
|
||||
"o": 111,
|
||||
"p": 112,
|
||||
"q": 113,
|
||||
"r": 114,
|
||||
"s": 115,
|
||||
"t": 116,
|
||||
"u": 117,
|
||||
"v": 118,
|
||||
"w": 119,
|
||||
"x": 120,
|
||||
"y": 121,
|
||||
"z": 122,
|
||||
"{": 123,
|
||||
"|": 124,
|
||||
"}": 125,
|
||||
"~": 126,
|
||||
"Delete": 127
|
||||
};
|
||||
26
WebApp/client/src/gamepadbutton.js
Normal file
26
WebApp/client/src/gamepadbutton.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export const GamepadButton = {
|
||||
DpadUp: 0,
|
||||
DpadDown: 1,
|
||||
DpadLeft: 2,
|
||||
DpadRight: 3,
|
||||
North: 4,
|
||||
East: 5,
|
||||
South: 6,
|
||||
West: 7,
|
||||
LeftStick: 8,
|
||||
RightStick: 9,
|
||||
LeftShoulder: 10,
|
||||
RightShoulder: 11,
|
||||
Start: 12,
|
||||
Select: 13,
|
||||
LeftTrigger: 32,
|
||||
RightTrigger: 33,
|
||||
X: 7, // West
|
||||
Y: 4, // North
|
||||
A: 6, // South,
|
||||
B: 5, // East,
|
||||
Cross: 6, // South,
|
||||
Square: 7, // West,
|
||||
Triangle: 4, //North,
|
||||
Circle: 5 // East,
|
||||
};
|
||||
44
WebApp/client/src/gamepadhandler.js
Normal file
44
WebApp/client/src/gamepadhandler.js
Normal file
@@ -0,0 +1,44 @@
|
||||
export class GamepadHandler extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this._controllers = {};
|
||||
window.requestAnimationFrame(this._updateStatus.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Gamepad} gamepad
|
||||
*/
|
||||
addGamepad(gamepad) {
|
||||
this._controllers[gamepad.index] = gamepad;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Gamepad} gamepad
|
||||
*/
|
||||
removeGamepad(gamepad) {
|
||||
delete this._controllers[gamepad.index];
|
||||
}
|
||||
|
||||
_updateStatus() {
|
||||
this._scanGamepad();
|
||||
for(let i in this._controllers) {
|
||||
const controller = this._controllers[i];
|
||||
|
||||
// gamepadupdated event type is own definition
|
||||
this.dispatchEvent(new GamepadEvent('gamepadupdated', {
|
||||
gamepad: controller
|
||||
}));
|
||||
}
|
||||
window.requestAnimationFrame(this._updateStatus.bind(this));
|
||||
}
|
||||
|
||||
_scanGamepad() {
|
||||
let gamepads = navigator.getGamepads();
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
if (gamepads[i] && (gamepads[i].index in this._controllers)) {
|
||||
this._controllers[gamepads[i].index] = gamepads[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
718
WebApp/client/src/inputdevice.js
Normal file
718
WebApp/client/src/inputdevice.js
Normal file
@@ -0,0 +1,718 @@
|
||||
import {
|
||||
MemoryHelper,
|
||||
} from "./memoryhelper.js";
|
||||
|
||||
import { CharNumber } from "./charnumber.js";
|
||||
import { Keymap } from "./keymap.js";
|
||||
import { MouseButton } from "./mousebutton.js";
|
||||
import { GamepadButton } from "./gamepadbutton.js";
|
||||
import { TouchPhase } from "./touchphase.js";
|
||||
import { TouchFlags } from "./touchflags.js";
|
||||
|
||||
export class FourCC {
|
||||
/**
|
||||
* {Number} _code;
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {String} a
|
||||
* @param {String} b
|
||||
* @param {String} c
|
||||
* @param {String} d
|
||||
*/
|
||||
constructor(a, b, c, d) {
|
||||
this._code = (a.charCodeAt() << 24)
|
||||
| (b.charCodeAt() << 16)
|
||||
| (c.charCodeAt() << 8)
|
||||
| d.charCodeAt();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Number}
|
||||
*/
|
||||
toInt32() {
|
||||
return this._code;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class InputDevice {
|
||||
|
||||
/**
|
||||
*
|
||||
* name;
|
||||
* layout;
|
||||
* deviceId;
|
||||
* usages;
|
||||
* description;
|
||||
*
|
||||
* _inputState;
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Number} name
|
||||
* @param {String} layout
|
||||
* @param {Number} deviceId
|
||||
* @param {String[]} usages
|
||||
* @param {Object} description
|
||||
*/
|
||||
constructor(name, layout, deviceId, usages, description) {
|
||||
this.name = name;
|
||||
this.layout = layout;
|
||||
this.deviceId = deviceId;
|
||||
this.usages = usages;
|
||||
this.description = description;
|
||||
|
||||
this._inputState = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {IInputState} state
|
||||
*/
|
||||
updateState(state) {
|
||||
this._inputState = state;
|
||||
}
|
||||
|
||||
queueEvent(event) {
|
||||
throw new Error(`Please implement this method. event:${event}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {IInputState}
|
||||
*/
|
||||
get currentState() {
|
||||
return this._inputState;
|
||||
}
|
||||
}
|
||||
|
||||
export class Mouse extends InputDevice {
|
||||
/**
|
||||
* @param {(MouseEvent|WheelEvent)} event
|
||||
*/
|
||||
queueEvent(event) {
|
||||
this.updateState(new MouseState(event));
|
||||
}
|
||||
}
|
||||
|
||||
export class Keyboard extends InputDevice {
|
||||
static get keycount() { return 110; }
|
||||
/**
|
||||
*
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
queueEvent(event) {
|
||||
this.updateState(new KeyboardState(event, this.currentState));
|
||||
}
|
||||
}
|
||||
|
||||
export class Touchscreen extends InputDevice {
|
||||
/**
|
||||
* @param {TouchScreenEvent} event
|
||||
*/
|
||||
queueEvent(event, time) {
|
||||
this.updateState(new TouchscreenState(event, this.currentState, time));
|
||||
}
|
||||
}
|
||||
|
||||
export class Gamepad extends InputDevice {
|
||||
/**
|
||||
* @param {GamepadButtonEvent | GamepadAxisEvent} event
|
||||
*/
|
||||
queueEvent(event) {
|
||||
this.updateState(new GamepadState(event));
|
||||
}
|
||||
}
|
||||
|
||||
export class InputEvent {
|
||||
static get invalidEventId() { return 0; }
|
||||
static get size() { return 20; }
|
||||
|
||||
/**
|
||||
* field offset 0
|
||||
* @member {Number} type;
|
||||
*
|
||||
* field offset 4
|
||||
* @member {Number} sizeInBytes;
|
||||
*
|
||||
* field offset 6
|
||||
* @member {Number} deviceId;
|
||||
*
|
||||
* field offset 8
|
||||
* @member {Number} time;
|
||||
*
|
||||
* field offset 16
|
||||
* @member {Number} eventId;
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Number} type
|
||||
* @param {Number} sizeInBytes
|
||||
* @param {Number} deviceId
|
||||
* @param {Number} time
|
||||
*/
|
||||
constructor(type, sizeInBytes, deviceId, time) {
|
||||
this.type = type;
|
||||
this.sizeInBytes = sizeInBytes;
|
||||
this.deviceId = deviceId;
|
||||
this.time = time;
|
||||
this.eventId = InputEvent.invalidEventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
let _buffer = new ArrayBuffer(InputEvent.size);
|
||||
let view = new DataView(_buffer);
|
||||
view.setInt32(0, this.type, true);
|
||||
view.setInt16(4, this.sizeInBytes, true);
|
||||
view.setInt16(6, this.deviceId, true);
|
||||
view.setFloat64(8, this.time, true);
|
||||
view.setInt16(16, this.sizeInBytes, true);
|
||||
return _buffer;
|
||||
}
|
||||
}
|
||||
|
||||
export class IInputState {
|
||||
/**
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
throw new Error('Please implement this field');
|
||||
}
|
||||
/**
|
||||
* @returns {Number}
|
||||
*/
|
||||
get format() {
|
||||
throw new Error('Please implement this field');
|
||||
}
|
||||
}
|
||||
|
||||
export class MouseState extends IInputState {
|
||||
static get size() { return 30; }
|
||||
static get format() { return new FourCC('M', 'O', 'U', 'S').toInt32(); }
|
||||
|
||||
/**
|
||||
* field offset 0
|
||||
* @member {Array} position;
|
||||
*
|
||||
* field offset 8
|
||||
* @member {Array} delta;
|
||||
*
|
||||
* field offset 16
|
||||
* @member {Array} scroll;
|
||||
*
|
||||
* field offset 24
|
||||
* @member {ArrayBuffer} buttons;
|
||||
*
|
||||
* field offset 26
|
||||
* @member {Array} displayIndex;
|
||||
*
|
||||
* field offset 28
|
||||
* @member {Array} clickCount;
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {MouseEvent | WheelEvent} event
|
||||
*/
|
||||
constructor(event) {
|
||||
super();
|
||||
|
||||
this.position = [event.clientX, event.clientY];
|
||||
this.delta = [event.movementX, -event.movementY];
|
||||
this.scroll = [0, 0];
|
||||
if(event.type === 'wheel') {
|
||||
this.scroll = [event.deltaX, event.deltaY];
|
||||
}
|
||||
this.buttons = new ArrayBuffer(2);
|
||||
|
||||
const left = event.buttons & 1 << 0;
|
||||
const right = event.buttons & 1 << 1;
|
||||
const middle = event.buttons & 1 << 2;
|
||||
const back = event.buttons & 1 << 3;
|
||||
const forward = event.buttons & 1 << 4;
|
||||
|
||||
MemoryHelper.writeSingleBit(this.buttons, MouseButton.Left, left);
|
||||
MemoryHelper.writeSingleBit(this.buttons, MouseButton.Right, right);
|
||||
MemoryHelper.writeSingleBit(this.buttons, MouseButton.Middle, middle);
|
||||
MemoryHelper.writeSingleBit(this.buttons, MouseButton.Forward, forward);
|
||||
MemoryHelper.writeSingleBit(this.buttons, MouseButton.Back, back);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
const size = MouseState.size;
|
||||
const buttons = new Uint16Array(this.buttons)[0];
|
||||
let _buffer = new ArrayBuffer(size);
|
||||
let view = new DataView(_buffer);
|
||||
view.setFloat32(0, this.position[0], true);
|
||||
view.setFloat32(4, this.position[1], true);
|
||||
view.setFloat32(8, this.delta[0], true);
|
||||
view.setFloat32(12, this.delta[1], true);
|
||||
view.setFloat32(16, this.scroll[0], true);
|
||||
view.setFloat32(20, this.scroll[1], true);
|
||||
view.setUint16(24, buttons, true);
|
||||
view.setUint16(26, this.displayIndex, true);
|
||||
view.setUint16(28, this.clickCount, true);
|
||||
return _buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Number}
|
||||
*/
|
||||
get format() {
|
||||
return MouseState.format;
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyboardState extends IInputState {
|
||||
static get sizeInBits() { return Keyboard.keycount; }
|
||||
static get sizeInBytes() { return (KeyboardState.sizeInBits + 7) >> 3; }
|
||||
static get format() { return new FourCC('K', 'E', 'Y', 'S').toInt32(); }
|
||||
|
||||
/**
|
||||
* field offset 0
|
||||
* @number {ArrayBuffer} keys;
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
constructor(event, state) {
|
||||
super();
|
||||
if (state == null || state.keys == null) {
|
||||
this.keys = new ArrayBuffer(KeyboardState.sizeInBytes);
|
||||
} else {
|
||||
this.keys = state.keys;
|
||||
}
|
||||
let value = false;
|
||||
switch(event.type) {
|
||||
case 'keydown':
|
||||
value = true;
|
||||
break;
|
||||
case 'keyup':
|
||||
value = false;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown event type ${event.type})`);
|
||||
}
|
||||
const key = Keymap[event.code];
|
||||
MemoryHelper.writeSingleBit(this.keys, key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
return this.keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Number}
|
||||
*/
|
||||
get format() {
|
||||
return KeyboardState.format;
|
||||
}
|
||||
}
|
||||
|
||||
export class TouchState {
|
||||
static get format() { return new FourCC('T', 'O', 'U', 'C').toInt32(); }
|
||||
static get size() { return 56; }
|
||||
static incrementTouchId() {
|
||||
if(TouchState._currentTouchId === undefined) {
|
||||
TouchState._currentTouchId = 0;
|
||||
}
|
||||
return ++TouchState._currentTouchId;
|
||||
}
|
||||
static prevTouches() {
|
||||
if(TouchState._prevTouches === undefined) {
|
||||
// max touch count is 10
|
||||
TouchState._prevTouches = new Array(10);
|
||||
}
|
||||
return TouchState._prevTouches;
|
||||
}
|
||||
|
||||
/**
|
||||
* field offset 0
|
||||
* @number {Number} touchId;
|
||||
* field offset 4
|
||||
* @number {Number[]} position;
|
||||
* field offset 12
|
||||
* @number {Number[]} delta;
|
||||
* field offset 20
|
||||
* @number {Number} pressure;
|
||||
* field offset 24
|
||||
* @number {Number[]} radius;
|
||||
* field offset 32
|
||||
* @number {Number} phase;
|
||||
* field offset 33
|
||||
* @number {Number} tapCount;
|
||||
* field offset 34
|
||||
* @number {Number} displayIndex;
|
||||
* field offset 35
|
||||
* @number {Number} flag;
|
||||
* field offset 36
|
||||
* @number {Number} padding;
|
||||
* field offset 40
|
||||
* @number {Number} startTime;
|
||||
* field offset 48
|
||||
* @number {Number[]} startPosition;
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @param {Touch} touchId
|
||||
* @param {TouchState} prevState
|
||||
* @param {Number[]} position
|
||||
* @param {Number} pressure
|
||||
* @param {Number[]} radius
|
||||
* @param {TouchPhase} phaseId
|
||||
* @param {Number} time
|
||||
*/
|
||||
constructor(touchId, prevState, position, pressure, radius, phaseId, time) {
|
||||
this.touchId = touchId;
|
||||
this.position = position != null ? position.slice() : null;
|
||||
if(phaseId == TouchPhase.Moved) {
|
||||
this.delta = [this.position[0] - prevState.position[0], this.position[1] - prevState.position[1]];
|
||||
} else {
|
||||
this.delta = [0, 0];
|
||||
}
|
||||
this.pressure = pressure;
|
||||
this.radius = radius != null ? radius.slice(): null;
|
||||
this.phaseId = phaseId;
|
||||
this.tapCount = 0;
|
||||
this.displayIndex = 0;
|
||||
this.flags = 0;
|
||||
this.padding = 0;
|
||||
if(phaseId == TouchPhase.Began) {
|
||||
this.startTime = time;
|
||||
this.startPosition = this.position.slice();
|
||||
} else {
|
||||
this.startTime = prevState != null ? prevState.startTime : null;
|
||||
this.startPosition = prevState != null ? prevState.startPosition.slice() : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
copy() {
|
||||
let state = new TouchState();
|
||||
state.touchId = this.touchId;
|
||||
state.position = this.position.slice();
|
||||
state.delta = this.delta.slice();
|
||||
state.pressure = this.pressure;
|
||||
state.radius = this.radius.slice();
|
||||
state.phaseId = this.phaseId;
|
||||
state.tapCount = this.tapCount;
|
||||
state.displayIndex = this.displayIndex;
|
||||
state.flags = this.flags;
|
||||
state.padding = this.padding;
|
||||
state.startTime = this.startTime;
|
||||
state.startPosition = this.startPosition.slice();
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
const size = TouchState.size; // todo
|
||||
let _buffer = new ArrayBuffer(size);
|
||||
let view = new DataView(_buffer);
|
||||
|
||||
view.setInt32(0, this.touchId, true);
|
||||
view.setFloat32(4, this.position[0], true);
|
||||
view.setFloat32(8, this.position[1], true);
|
||||
view.setFloat32(12, this.delta[0], true);
|
||||
view.setFloat32(16, this.delta[1], true);
|
||||
view.setFloat32(20, this.pressure, true);
|
||||
view.setFloat32(24, this.radius[0], true);
|
||||
view.setFloat32(28, this.radius[1], true);
|
||||
view.setInt8(32, this.phaseId, true);
|
||||
view.setInt8(33, this.tapCount, true);
|
||||
view.setInt8(34, this.displayIndex, true);
|
||||
view.setInt8(35, this.flags, true);
|
||||
view.setInt32(36, this.padding, true);
|
||||
view.setFloat64(40, this.startTime, true);
|
||||
view.setFloat32(48, this.startPosition[0], true);
|
||||
view.setFloat32(52, this.startPosition[1], true);
|
||||
return _buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Number}
|
||||
*/
|
||||
get format() {
|
||||
return TouchState.format;
|
||||
}
|
||||
}
|
||||
|
||||
export class TouchscreenState extends IInputState {
|
||||
static get maxTouches() { return 10; }
|
||||
static get format() { return new FourCC('T', 'S', 'C', 'R').toInt32(); }
|
||||
static convertPhaseId(type) {
|
||||
let phaseId = TouchPhase.Stationary;
|
||||
switch(type) {
|
||||
case 'touchstart':
|
||||
phaseId = TouchPhase.Began; break;
|
||||
case 'touchend':
|
||||
phaseId = TouchPhase.Ended; break;
|
||||
case 'touchmove':
|
||||
phaseId = TouchPhase.Moved; break;
|
||||
case 'touchcancel':
|
||||
phaseId = TouchPhase.Canceled; break;
|
||||
}
|
||||
return phaseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
* @param {TouchScreenState} state
|
||||
* @param {Date} time
|
||||
*/
|
||||
constructor(event, state, time) {
|
||||
super();
|
||||
|
||||
switch(event.type) {
|
||||
// `click` event is called when releasing mouse button or finger on screen.
|
||||
case 'click' : {
|
||||
this.touchData = new Array(state.touchData.length);
|
||||
for(let i = 0; i < state.touchData.length; i++) {
|
||||
this.touchData[i] = state.touchData[i];
|
||||
if(this.touchData[i].phaseId == TouchPhase.Ended) {
|
||||
this.touchData[i].tapCount = 1;
|
||||
this.touchData[i].flags |= TouchFlags.Tap;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
let touches = event.changedTouches;
|
||||
this.touchData = new Array(touches.length);
|
||||
for(let i = 0; i < touches.length; i++) {
|
||||
const touch = touches[i];
|
||||
const position = [touch.clientX, touch.clientY];
|
||||
const phaseId = TouchscreenState.convertPhaseId(event.type);
|
||||
const pressure = touch.force;
|
||||
const radius = [touch.radiusX, touch.radiusY];
|
||||
|
||||
// `touchId` in InputSystem must be set uniquely.
|
||||
// The numbers of `touch.identifier` in Web API are reused, so these are not unique.
|
||||
const touchId = phaseId == TouchPhase.Began ? TouchState.incrementTouchId() : TouchState.prevTouches()[touch.identifier].touchId;
|
||||
const prevState = phaseId != TouchPhase.Began ? TouchState.prevTouches()[touch.identifier] : null;
|
||||
const touchData = new TouchState(touchId, prevState, position, pressure, radius, phaseId, time);
|
||||
|
||||
// cache state
|
||||
TouchState.prevTouches()[touch.identifier] = touchData;
|
||||
this.touchData[i] = touchData;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
const size = TouchState.size * this.touchData.length;
|
||||
let _buffer = new ArrayBuffer(size);
|
||||
let view = new Uint8Array(_buffer);
|
||||
for(let i = 0; i < this.touchData.length; i++) {
|
||||
view.set(new Uint8Array(this.touchData[i].buffer), TouchState.size * i);
|
||||
}
|
||||
return _buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Number}
|
||||
*/
|
||||
get format() {
|
||||
return TouchscreenState.format;
|
||||
}
|
||||
}
|
||||
|
||||
export class GamepadState extends IInputState {
|
||||
static get size() { return 28; }
|
||||
static get format() { return new FourCC('G', 'P', 'A', 'D').toInt32(); }
|
||||
|
||||
/**
|
||||
* field offset 0
|
||||
* @member buttons;
|
||||
*
|
||||
* field offset 4
|
||||
* @member leftStick;
|
||||
*
|
||||
* field offset 12
|
||||
* @member rightStick;
|
||||
*
|
||||
* field offset 20
|
||||
* @member leftTrigger;
|
||||
*
|
||||
* field offset 24
|
||||
* @member rightTrigger;
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {GamepadButtonEvent | GamepadAxisEvent} event
|
||||
*/
|
||||
constructor(event) {
|
||||
super();
|
||||
const gamepad = event.gamepad;
|
||||
const buttons = event.gamepad.buttons;
|
||||
|
||||
this.buttons = new ArrayBuffer(4);
|
||||
this.leftStick = [ gamepad.axes[0], -gamepad.axes[1] ];
|
||||
this.rightStick = [ gamepad.axes[2], -gamepad.axes[3] ];
|
||||
this.leftTrigger = buttons[6].value;
|
||||
this.rightTrigger = buttons[7].value;
|
||||
|
||||
// see https://w3c.github.io/gamepad/#remapping
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.A, buttons[0].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.B, buttons[1].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.X, buttons[2].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.Y, buttons[3].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.LeftShoulder, buttons[4].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.RightShoulder, buttons[5].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.LeftTrigger, buttons[6].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.RightTrigger, buttons[7].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.Select, buttons[8].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.Start, buttons[9].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.LeftStick, buttons[10].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.RightStick, buttons[11].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.DpadUp, buttons[12].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.DpadDown, buttons[13].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.DpadLeft, buttons[14].pressed);
|
||||
MemoryHelper.writeSingleBit(this.buttons, GamepadButton.DpadRight, buttons[15].pressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
const size = GamepadState.size;
|
||||
let _buffer = new ArrayBuffer(size);
|
||||
let view = new DataView(_buffer);
|
||||
view.setUint32(0, new Uint32Array(this.buttons)[0], true);
|
||||
view.setFloat32(4, this.leftStick[0], true);
|
||||
view.setFloat32(8, this.leftStick[1], true);
|
||||
view.setFloat32(12, this.rightStick[0], true);
|
||||
view.setFloat32(16, this.rightStick[1], true);
|
||||
view.setFloat32(20, this.leftTrigger, true);
|
||||
view.setFloat32(24, this.rightTrigger, true);
|
||||
return _buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Number}
|
||||
*/
|
||||
get format() {
|
||||
return GamepadState.format;
|
||||
}
|
||||
}
|
||||
|
||||
export class TextEvent {
|
||||
static get format() { return new FourCC('T', 'E', 'X', 'T').toInt32(); }
|
||||
|
||||
/**
|
||||
* field offset 0
|
||||
* @member {InputEvent} baseEvent;
|
||||
*
|
||||
* field offset 20
|
||||
* @member {Number} character;
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Number} deviceId
|
||||
* @param {KeyboardEvent} event
|
||||
* @param {Number} time
|
||||
* @returns {TextEvent}
|
||||
|
||||
*/
|
||||
static create(deviceId, event, time) {
|
||||
const eventSize = InputEvent.size + MemoryHelper.sizeOfInt;
|
||||
|
||||
let textEvent = new TextEvent();
|
||||
textEvent.baseEvent = new InputEvent(TextEvent.format, eventSize, deviceId, time);
|
||||
textEvent.character = CharNumber[event.key];
|
||||
return textEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
const size = InputEvent.size + MemoryHelper.sizeOfInt;
|
||||
let _buffer = new ArrayBuffer(size);
|
||||
let arrayView = new Uint8Array(_buffer);
|
||||
let dataView = new DataView(_buffer);
|
||||
arrayView.set(new Uint8Array(this.baseEvent.buffer), 0);
|
||||
dataView.setInt32(InputEvent.size, this.character, true);
|
||||
return _buffer;
|
||||
}
|
||||
}
|
||||
|
||||
export class StateEvent {
|
||||
static get format() { return new FourCC('S', 'T', 'A', 'T').toInt32(); }
|
||||
|
||||
/**
|
||||
* field offset 0
|
||||
* @member {InputEvent} baseEvent;
|
||||
*
|
||||
* field offset 20
|
||||
* @member {Number} stateFormat;
|
||||
*
|
||||
* field offset 24
|
||||
* @member {ArrayBuffer} stateData;
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {InputDevice} device
|
||||
* @param {Number} time
|
||||
* @returns {StateEvent}
|
||||
*/
|
||||
static from(device, time) {
|
||||
return StateEvent.fromState(device.currentState, device.deviceId, time);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {IInputState} state
|
||||
* @param {Number} deviceId
|
||||
* @param {Number} time
|
||||
*/
|
||||
static fromState(state, deviceId, time) {
|
||||
const stateData = state.buffer;
|
||||
const stateSize = stateData.byteLength;
|
||||
const eventSize = InputEvent.size + MemoryHelper.sizeOfInt + stateSize;
|
||||
|
||||
let stateEvent = new StateEvent();
|
||||
stateEvent.baseEvent = new InputEvent(StateEvent.format, eventSize, deviceId, time);
|
||||
stateEvent.stateFormat = state.format;
|
||||
stateEvent.stateData = stateData;
|
||||
return stateEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
const stateSize = this.stateData.byteLength;
|
||||
const size = InputEvent.size + MemoryHelper.sizeOfInt + stateSize;
|
||||
let _buffer = new ArrayBuffer(size);
|
||||
let uint8View = new Uint8Array(_buffer);
|
||||
let dataView = new DataView(_buffer);
|
||||
uint8View.set(new Uint8Array(this.baseEvent.buffer), 0);
|
||||
dataView.setInt32(InputEvent.size, this.stateFormat, true);
|
||||
uint8View.set(new Uint8Array(this.stateData), InputEvent.size+MemoryHelper.sizeOfInt);
|
||||
return _buffer;
|
||||
}
|
||||
}
|
||||
299
WebApp/client/src/inputremoting.js
Normal file
299
WebApp/client/src/inputremoting.js
Normal file
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
StateEvent,
|
||||
} from "./inputdevice.js";
|
||||
|
||||
import {
|
||||
MemoryHelper
|
||||
} from "./memoryhelper.js";
|
||||
|
||||
export class LocalInputManager {
|
||||
constructor() {
|
||||
this._onevent = new EventTarget();
|
||||
}
|
||||
|
||||
/**
|
||||
* event type 'event', 'changedeviceusage'
|
||||
* @return {Event}
|
||||
*/
|
||||
get onEvent() {
|
||||
return this._onevent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Event}
|
||||
*/
|
||||
get devices() {
|
||||
throw new Error(`Please implement this method.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Number} time (sec)
|
||||
*/
|
||||
get startTime() {
|
||||
return this._startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Number} time (sec)
|
||||
*/
|
||||
get timeSinceStartup() {
|
||||
return Date.now()/1000 - this.startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} time (sec)
|
||||
*/
|
||||
setStartTime(time) {
|
||||
this._startTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
export const InputDeviceChange = {
|
||||
Added: 0,
|
||||
Removed: 1,
|
||||
Disconnected: 2,
|
||||
Reconnected: 3,
|
||||
Enabled: 4,
|
||||
Disabled: 5,
|
||||
UsageChanged: 6,
|
||||
ConfigurationChanged: 7,
|
||||
Destroyed: 8,
|
||||
};
|
||||
|
||||
export class InputRemoting {
|
||||
/**
|
||||
* @param {LocalInputManager} manager
|
||||
*/
|
||||
constructor(manager) {
|
||||
this._localManager = manager;
|
||||
this._subscribers = new Array();
|
||||
this._sending = false;
|
||||
}
|
||||
|
||||
startSending() {
|
||||
if(this._sending) {
|
||||
return;
|
||||
}
|
||||
this._sending = true;
|
||||
|
||||
const onEvent = e => {
|
||||
this._sendEvent(e.detail.event);
|
||||
};
|
||||
|
||||
const onDeviceChange = e => {
|
||||
this._sendDeviceChange(e.detail.device, e.detail.change);
|
||||
};
|
||||
|
||||
this._localManager.setStartTime(Date.now()/1000);
|
||||
this._localManager.onEvent.addEventListener("event", onEvent);
|
||||
this._localManager.onEvent.addEventListener("changedeviceusage", onDeviceChange);
|
||||
this._sendInitialMessages();
|
||||
}
|
||||
|
||||
stopSending() {
|
||||
if (!this._sending) {
|
||||
return;
|
||||
}
|
||||
this._sending = false;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Observer} observer
|
||||
*/
|
||||
subscribe(observer) {
|
||||
this._subscribers.push(observer);
|
||||
}
|
||||
|
||||
_sendInitialMessages() {
|
||||
this._sendAllGeneratedLayouts();
|
||||
this._sendAllDevices();
|
||||
}
|
||||
|
||||
_sendAllGeneratedLayouts() {
|
||||
// todo:
|
||||
}
|
||||
|
||||
_sendAllDevices() {
|
||||
var devices = this._localManager.devices;
|
||||
if(devices == null)
|
||||
return;
|
||||
for (const device of devices) {
|
||||
this._sendDevice(device);
|
||||
}
|
||||
}
|
||||
|
||||
_sendDevice(device) {
|
||||
const newDeviceMessage = NewDeviceMsg.create(device);
|
||||
this._send(newDeviceMessage);
|
||||
|
||||
// Send current state. We do this here in this case as the device
|
||||
// may have been added some time ago and thus have already received events.
|
||||
|
||||
// todo:
|
||||
// const stateEventMessage = NewEventsMsg.createStateEvent(device);
|
||||
// this._send(stateEventMessage);
|
||||
}
|
||||
|
||||
_sendEvent(event) {
|
||||
const message = NewEventsMsg.create(event);
|
||||
this._send(message);
|
||||
}
|
||||
|
||||
_sendDeviceChange(device, change) {
|
||||
if (this._subscribers == null)
|
||||
return;
|
||||
|
||||
let msg = null;
|
||||
switch (change) {
|
||||
case InputDeviceChange.Added:
|
||||
msg = NewDeviceMsg.Create(device);
|
||||
break;
|
||||
case InputDeviceChange.Removed:
|
||||
msg = RemoveDeviceMsg.Create(device);
|
||||
break;
|
||||
case InputDeviceChange.UsageChanged:
|
||||
msg = ChangeUsageMsg.Create(device);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
this._send(msg);
|
||||
}
|
||||
|
||||
_send(message) {
|
||||
for(let subscriber of this._subscribers) {
|
||||
subscriber.onNext(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageType = {
|
||||
Connect: 0,
|
||||
Disconnect: 1,
|
||||
NewLayout: 2,
|
||||
NewDevice: 3,
|
||||
NewEvents: 4,
|
||||
RemoveDevice: 5,
|
||||
RemoveLayout: 6,
|
||||
ChangeUsages: 7,
|
||||
StartSending: 8,
|
||||
StopSending: 9,
|
||||
};
|
||||
|
||||
export class Message {
|
||||
/**
|
||||
* field offset 0
|
||||
* {Number} participant_id;
|
||||
*
|
||||
* field offset 4
|
||||
* {Number} type;
|
||||
*
|
||||
* field offset 8
|
||||
* {Number} length;
|
||||
*
|
||||
* field offset 12
|
||||
* {ArrayBuffer} data;
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} participantId
|
||||
* @param {MessageType} type
|
||||
* @param {ArrayBuffer} data
|
||||
*/
|
||||
constructor(participantId, type, data) {
|
||||
this.participant_id = participantId;
|
||||
this.type = type;
|
||||
this.length = data.byteLength;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
get buffer() {
|
||||
const totalSize =
|
||||
MemoryHelper.sizeOfInt + // size of this.participant_id
|
||||
MemoryHelper.sizeOfInt + // size of this.type
|
||||
MemoryHelper.sizeOfInt + // size of this.length
|
||||
this.data.byteLength; // size of this.data
|
||||
|
||||
let buffer = new ArrayBuffer(totalSize);
|
||||
let dataView = new DataView(buffer);
|
||||
let uint8view = new Uint8Array(buffer);
|
||||
dataView.setUint32(0, this.participant_id, true);
|
||||
dataView.setUint32(4, this.type, true);
|
||||
dataView.setUint32(8, this.length, true);
|
||||
uint8view.set(new Uint8Array(this.data), 12);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
export class NewDeviceMsg {
|
||||
/**
|
||||
* @param {InputDevice} device
|
||||
* @returns {Message}
|
||||
*/
|
||||
static create(device) {
|
||||
const data = {
|
||||
name: device.name,
|
||||
layout: device.layout,
|
||||
deviceId: device.deviceId,
|
||||
variants: device.variants,
|
||||
description: device.description
|
||||
};
|
||||
const json = JSON.stringify(data);
|
||||
let buffer = new ArrayBuffer(json.length*2); // 2 bytes for each char
|
||||
let view = new Uint8Array(buffer);
|
||||
const length = json.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
view[i] = json.charCodeAt(i);
|
||||
}
|
||||
return new Message(0, MessageType.NewDevice, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
export class NewEventsMsg {
|
||||
/**
|
||||
*
|
||||
* @param {InputDevice} device
|
||||
* @returns {Message}
|
||||
*/
|
||||
static createStateEvent(device) {
|
||||
const events = StateEvent.from(device);
|
||||
return NewEventsMsg.create(events);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {StateEvent} event
|
||||
* @returns {Message}
|
||||
*/
|
||||
static create(event) {
|
||||
return new Message(0, MessageType.NewEvents, event.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoveDeviceMsg {
|
||||
/**
|
||||
*
|
||||
* @param {InputDevice} device
|
||||
* @returns {Message}
|
||||
*/
|
||||
static create(device) {
|
||||
let buffer = new ArrayBuffer(MemoryHelper.sizeOfInt);
|
||||
let view = new DataView(buffer);
|
||||
view.setInt32(device.deviceId);
|
||||
return new Message(0, MessageType.RemoveDevice, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
export class ChangeUsageMsg {
|
||||
|
||||
static create(device) {
|
||||
// todo:
|
||||
throw new Error(`ChangeUsageMsg class is not implemented. device=${device}`);
|
||||
}
|
||||
}
|
||||
120
WebApp/client/src/keymap.js
Normal file
120
WebApp/client/src/keymap.js
Normal file
@@ -0,0 +1,120 @@
|
||||
export const Keymap = {
|
||||
"Space": 1,
|
||||
"Enter": 2,
|
||||
"Tab": 3,
|
||||
"Backquote": 4,
|
||||
"Quote": 5,
|
||||
"Semicolon": 6,
|
||||
"Comma": 7,
|
||||
"Period": 8,
|
||||
"Slash": 9,
|
||||
"Backslash": 10,
|
||||
"BracketLeft": 11,
|
||||
"BracketRight": 12,
|
||||
"Minus": 13,
|
||||
"Equal": 14,
|
||||
"KeyA": 15,
|
||||
"KeyB": 16,
|
||||
"KeyC": 17,
|
||||
"KeyD": 18,
|
||||
"KeyE": 19,
|
||||
"KeyF": 20,
|
||||
"KeyG": 21,
|
||||
"KeyH": 22,
|
||||
"KeyI": 23,
|
||||
"KeyJ": 24,
|
||||
"KeyK": 25,
|
||||
"KeyL": 26,
|
||||
"KeyM": 27,
|
||||
"KeyN": 28,
|
||||
"KeyO": 29,
|
||||
"KeyP": 30,
|
||||
"KeyQ": 31,
|
||||
"KeyR": 32,
|
||||
"KeyS": 33,
|
||||
"KeyT": 34,
|
||||
"KeyU": 35,
|
||||
"KeyV": 36,
|
||||
"KeyW": 37,
|
||||
"KeyX": 38,
|
||||
"KeyY": 39,
|
||||
"KeyZ": 40,
|
||||
"Digit1": 41,
|
||||
"Digit2": 42,
|
||||
"Digit3": 43,
|
||||
"Digit4": 44,
|
||||
"Digit5": 45,
|
||||
"Digit6": 46,
|
||||
"Digit7": 47,
|
||||
"Digit8": 48,
|
||||
"Digit9": 49,
|
||||
"Digit0": 50,
|
||||
"ShiftLeft": 51,
|
||||
"ShiftRight": 52,
|
||||
"AltLeft": 53,
|
||||
"AltRight": 54,
|
||||
// "AltGr": 54,
|
||||
"ControlLeft": 55,
|
||||
"ControlRight": 56,
|
||||
"MetaLeft": 57,
|
||||
"MetaRight": 58,
|
||||
// "LeftWindows": 57,
|
||||
// "RightWindows": 58,
|
||||
// "LeftApple": 57,
|
||||
// "RightApple": 58,
|
||||
// "LeftCommand": 57,
|
||||
// "RightCommand": 58,
|
||||
"ContextMenu": 59,
|
||||
"Escape": 60,
|
||||
"ArrowLeft": 61,
|
||||
"ArrowRight": 62,
|
||||
"ArrowUp": 63,
|
||||
"ArrowDown": 64,
|
||||
"Backspace": 65,
|
||||
"PageDown": 66,
|
||||
"PageUp": 67,
|
||||
"Home": 68,
|
||||
"End": 69,
|
||||
"Insert": 70,
|
||||
"Delete": 71,
|
||||
"CapsLock": 72,
|
||||
"NumLock": 73,
|
||||
"PrintScreen": 74,
|
||||
"ScrollLock": 75,
|
||||
"Pause": 76,
|
||||
"NumpadEnter": 77,
|
||||
"NumpadDivide": 78,
|
||||
"NumpadMultiply": 79,
|
||||
"NumpadAdd": 80,
|
||||
"NumpadSubtract": 81,
|
||||
"NumpadDecimal": 82,
|
||||
"NumpadEquals": 83,
|
||||
"Numpad0": 84,
|
||||
"Numpad1": 85,
|
||||
"Numpad2": 86,
|
||||
"Numpad3": 87,
|
||||
"Numpad4": 88,
|
||||
"Numpad5": 89,
|
||||
"Numpad6": 90,
|
||||
"Numpad7": 91,
|
||||
"Numpad8": 92,
|
||||
"Numpad9": 93,
|
||||
"F1": 94,
|
||||
"F2": 95,
|
||||
"F3": 96,
|
||||
"F4": 97,
|
||||
"F5": 98,
|
||||
"F6": 99,
|
||||
"F7": 100,
|
||||
"F8": 101,
|
||||
"F9": 102,
|
||||
"F10": 103,
|
||||
"F11": 104,
|
||||
"F12": 105,
|
||||
// "OEM1": 106,
|
||||
// "OEM2": 107,
|
||||
// "OEM3": 108,
|
||||
// "OEM4": 109,
|
||||
// "OEM5": 110,
|
||||
// "IMESelected": 111,
|
||||
};
|
||||
29
WebApp/client/src/logger.js
Normal file
29
WebApp/client/src/logger.js
Normal file
@@ -0,0 +1,29 @@
|
||||
let isDebug = false;
|
||||
|
||||
export function enable() {
|
||||
isDebug = true;
|
||||
}
|
||||
|
||||
export function disable() {
|
||||
isDebug = false;
|
||||
}
|
||||
|
||||
export function debug(msg) {
|
||||
isDebug && console.debug(msg);
|
||||
}
|
||||
|
||||
export function info(msg) {
|
||||
isDebug && console.info(msg);
|
||||
}
|
||||
|
||||
export function log(msg) {
|
||||
isDebug && console.log(msg);
|
||||
}
|
||||
|
||||
export function warn(msg) {
|
||||
isDebug && console.warn(msg);
|
||||
}
|
||||
|
||||
export function error(msg) {
|
||||
isDebug && console.error(msg);
|
||||
}
|
||||
28
WebApp/client/src/memoryhelper.js
Normal file
28
WebApp/client/src/memoryhelper.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export class MemoryHelper {
|
||||
/**
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @param {number} bitOffset
|
||||
* @param {boolean} value
|
||||
*/
|
||||
static writeSingleBit(buffer, bitOffset, value) {
|
||||
let view = new Uint8Array(buffer);
|
||||
const index = Math.floor(bitOffset / 8);
|
||||
bitOffset = bitOffset % 8;
|
||||
const byte = view[index];
|
||||
let newByte = 1 << bitOffset;
|
||||
if(value) {
|
||||
newByte = newByte | byte;
|
||||
}
|
||||
else {
|
||||
newByte = ~newByte & byte;
|
||||
}
|
||||
view[index] = newByte;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Number}
|
||||
*/
|
||||
static get sizeOfInt() {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
7
WebApp/client/src/mousebutton.js
Normal file
7
WebApp/client/src/mousebutton.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const MouseButton = {
|
||||
Left: 0,
|
||||
Right: 1,
|
||||
Middle: 2,
|
||||
Foaward: 3,
|
||||
Back: 4,
|
||||
};
|
||||
187
WebApp/client/src/peer.js
Normal file
187
WebApp/client/src/peer.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as Logger from "./logger.js";
|
||||
|
||||
export default class Peer extends EventTarget {
|
||||
constructor(connectionId, polite, config, resendIntervalMsec = 5000) {
|
||||
super();
|
||||
const _this = this;
|
||||
this.connectionId = connectionId;
|
||||
this.polite = polite;
|
||||
this.config = config;
|
||||
this.pc = new RTCPeerConnection(this.config);
|
||||
this.makingOffer = false;
|
||||
this.waitingAnswer = false;
|
||||
this.ignoreOffer = false;
|
||||
this.srdAnswerPending = false;
|
||||
this.log = str => void Logger.log(`[${_this.polite ? 'POLITE' : 'IMPOLITE'}] ${str}`);
|
||||
this.warn = str => void Logger.warn(`[${_this.polite ? 'POLITE' : 'IMPOLITE'}] ${str}`);
|
||||
this.assert_equals = window.assert_equals ? window.assert_equals : (a, b, msg) => { if (a === b) { return; } throw new Error(`${msg} expected ${b} but got ${a}`); };
|
||||
this.interval = resendIntervalMsec;
|
||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||
|
||||
this.pc.ontrack = e => {
|
||||
_this.log(`ontrack:${e}`);
|
||||
_this.dispatchEvent(new CustomEvent('trackevent', { detail: e }));
|
||||
};
|
||||
this.pc.ondatachannel = e => {
|
||||
_this.log(`ondatachannel:${e}`);
|
||||
_this.dispatchEvent(new CustomEvent('adddatachannel', { detail: e }));
|
||||
};
|
||||
this.pc.onicecandidate = ({ candidate }) => {
|
||||
_this.log(`send candidate:${candidate}`);
|
||||
if (candidate == null) {
|
||||
return;
|
||||
}
|
||||
_this.dispatchEvent(new CustomEvent('sendcandidate', { detail: { connectionId: _this.connectionId, candidate: candidate.candidate, sdpMLineIndex: candidate.sdpMLineIndex, sdpMid: candidate.sdpMid } }));
|
||||
};
|
||||
|
||||
this.pc.onnegotiationneeded = this._onNegotiation.bind(this);
|
||||
|
||||
this.pc.onsignalingstatechange = () => {
|
||||
_this.log(`signalingState changed:${_this.pc.signalingState}`);
|
||||
};
|
||||
|
||||
this.pc.oniceconnectionstatechange = () => {
|
||||
_this.log(`iceConnectionState changed:${_this.pc.iceConnectionState}`);
|
||||
if (_this.pc.iceConnectionState === 'disconnected') {
|
||||
this.dispatchEvent(new Event('disconnect'));
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.onicegatheringstatechange = () => {
|
||||
_this.log(`iceGatheringState changed:${_this.pc.iceGatheringState}'`);
|
||||
};
|
||||
|
||||
this.loopResendOffer();
|
||||
}
|
||||
|
||||
async _onNegotiation() {
|
||||
try {
|
||||
this.log(`SLD due to negotiationneeded`);
|
||||
this.assert_equals(this.pc.signalingState, 'stable', 'negotiationneeded always fires in stable state');
|
||||
this.assert_equals(this.makingOffer, false, 'negotiationneeded not already in progress');
|
||||
this.makingOffer = true;
|
||||
await this.pc.setLocalDescription();
|
||||
this.assert_equals(this.pc.signalingState, 'have-local-offer', 'negotiationneeded not racing with onmessage');
|
||||
this.assert_equals(this.pc.localDescription.type, 'offer', 'negotiationneeded SLD worked');
|
||||
this.waitingAnswer = true;
|
||||
this.dispatchEvent(new CustomEvent('sendoffer', { detail: { connectionId: this.connectionId, sdp: this.pc.localDescription.sdp } }));
|
||||
} catch (e) {
|
||||
this.log(e);
|
||||
} finally {
|
||||
this.makingOffer = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loopResendOffer() {
|
||||
while (this.connectionId) {
|
||||
if (this.pc && this.waitingAnswer) {
|
||||
this.dispatchEvent(new CustomEvent('sendoffer', { detail: { connectionId: this.connectionId, sdp: this.pc.localDescription.sdp } }));
|
||||
}
|
||||
await this.sleep(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.connectionId = null;
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
getTransceivers(connectionId) {
|
||||
if (this.connectionId != connectionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.pc.getTransceivers();
|
||||
}
|
||||
|
||||
addTrack(connectionId, track) {
|
||||
if (this.connectionId != connectionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.pc.addTrack(track);
|
||||
}
|
||||
|
||||
addTransceiver(connectionId, trackOrKind, init) {
|
||||
if (this.connectionId != connectionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.pc.addTransceiver(trackOrKind, init);
|
||||
}
|
||||
|
||||
createDataChannel(connectionId, label) {
|
||||
if (this.connectionId != connectionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.pc.createDataChannel(label);
|
||||
}
|
||||
|
||||
async getStats(connectionId) {
|
||||
if (this.connectionId != connectionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.pc.getStats();
|
||||
}
|
||||
|
||||
async onGotDescription(connectionId, description) {
|
||||
if (this.connectionId != connectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _this = this;
|
||||
const isStable =
|
||||
this.pc.signalingState == 'stable' ||
|
||||
(this.pc.signalingState == 'have-local-offer' && this.srdAnswerPending);
|
||||
this.ignoreOffer =
|
||||
description.type == 'offer' && !this.polite && (this.makingOffer || !isStable);
|
||||
|
||||
if (this.ignoreOffer) {
|
||||
_this.log(`glare - ignoring offer`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.waitingAnswer = false;
|
||||
this.srdAnswerPending = description.type == 'answer';
|
||||
_this.log(`SRD(${description.type})`);
|
||||
await this.pc.setRemoteDescription(description);
|
||||
this.srdAnswerPending = false;
|
||||
|
||||
if (description.type == 'offer') {
|
||||
_this.dispatchEvent(new CustomEvent('ongotoffer', { detail: { connectionId: _this.connectionId } }));
|
||||
|
||||
_this.assert_equals(this.pc.signalingState, 'have-remote-offer', 'Remote offer');
|
||||
_this.assert_equals(this.pc.remoteDescription.type, 'offer', 'SRD worked');
|
||||
_this.log('SLD to get back to stable');
|
||||
await this.pc.setLocalDescription();
|
||||
_this.assert_equals(this.pc.signalingState, 'stable', 'onmessage not racing with negotiationneeded');
|
||||
_this.assert_equals(this.pc.localDescription.type, 'answer', 'onmessage SLD worked');
|
||||
_this.dispatchEvent(new CustomEvent('sendanswer', { detail: { connectionId: _this.connectionId, sdp: _this.pc.localDescription.sdp } }));
|
||||
|
||||
} else {
|
||||
_this.dispatchEvent(new CustomEvent('ongotanswer', { detail: { connectionId: _this.connectionId } }));
|
||||
|
||||
_this.assert_equals(this.pc.remoteDescription.type, 'answer', 'Answer was set');
|
||||
_this.assert_equals(this.pc.signalingState, 'stable', 'answered');
|
||||
this.pc.dispatchEvent(new Event('negotiated'));
|
||||
}
|
||||
}
|
||||
|
||||
async onGotCandidate(connectionId, candidate) {
|
||||
if (this.connectionId != connectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pc.addIceCandidate(candidate);
|
||||
} catch (e) {
|
||||
if (this.pc && !this.ignoreOffer)
|
||||
this.warn(`${this.pc} this candidate can't accept current signaling state ${this.pc.signalingState}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
124
WebApp/client/src/pointercorrect.js
Normal file
124
WebApp/client/src/pointercorrect.js
Normal file
@@ -0,0 +1,124 @@
|
||||
export const LetterBoxType = {
|
||||
Vertical: 0,
|
||||
Horizontal: 1
|
||||
};
|
||||
|
||||
export class PointerCorrector {
|
||||
/**
|
||||
* @param {Number} videoWidth
|
||||
* @param {Number} videoHeight
|
||||
* @param {HTMLVideoElement} videoElem
|
||||
*/
|
||||
constructor(videoWidth, videoHeight, videoElem) {
|
||||
this.reset(videoWidth, videoHeight, videoElem);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number[]} position MouseEvent.clientX, MouseEvent.clientY
|
||||
* @returns {Number[]}
|
||||
*/
|
||||
map(position) {
|
||||
var rect = this._videoElem.getBoundingClientRect();
|
||||
const _position = new Array(2);
|
||||
|
||||
// (1) set origin point to zero
|
||||
_position[0] = position[0] - rect.left;
|
||||
_position[1] = position[1] - rect.top;
|
||||
|
||||
// (2) translate Unity coordinate system (reverse y-axis)
|
||||
_position[1] = rect.height - _position[1];
|
||||
|
||||
// (3) add offset of letterbox
|
||||
_position[0] -= this._contentRect.x;
|
||||
_position[1] -= this._contentRect.y;
|
||||
|
||||
// (4) mapping element rectangle to video rectangle
|
||||
_position[0] = _position[0] / this._contentRect.width * this._videoWidth;
|
||||
_position[1] = _position[1] / this._contentRect.height * this._videoHeight;
|
||||
|
||||
return _position;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} videoWidth
|
||||
*/
|
||||
setVideoWidth(videoWidth) {
|
||||
this._videoWidth = videoWidth;
|
||||
this._reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} videoHeight
|
||||
*/
|
||||
setVideoHeight(videoHeight) {
|
||||
this._videoHeight = videoHeight;
|
||||
this._reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLVideoElement} videoElem
|
||||
*/
|
||||
setRect(videoElem) {
|
||||
this._videoElem = videoElem;
|
||||
this._reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} videoWidth
|
||||
* @param {Number} videoHeight
|
||||
* @param {HTMLVideoElement} videoElem
|
||||
*/
|
||||
reset(videoWidth, videoHeight, videoElem) {
|
||||
this._videoWidth = videoWidth;
|
||||
this._videoHeight = videoHeight;
|
||||
this._videoElem = videoElem;
|
||||
this._reset();
|
||||
}
|
||||
|
||||
get letterBoxType() {
|
||||
const videoRatio = this._videoHeight / this._videoWidth;
|
||||
var rect = this._videoElem.getBoundingClientRect();
|
||||
const rectRatio = rect.height / rect.width;
|
||||
return videoRatio > rectRatio ? LetterBoxType.Vertical : LetterBoxType.Horizontal;
|
||||
}
|
||||
|
||||
get letterBoxSize() {
|
||||
var rect = this._videoElem.getBoundingClientRect();
|
||||
switch(this.letterBoxType) {
|
||||
case LetterBoxType.Horizontal: {
|
||||
const ratioWidth = rect.width / this._videoWidth;
|
||||
const height = this._videoHeight * ratioWidth;
|
||||
return (rect.height - height) * 0.5;
|
||||
}
|
||||
case LetterBoxType.Vertical: {
|
||||
const ratioHeight = rect.height / this._videoHeight;
|
||||
const width = this._videoWidth * ratioHeight;
|
||||
return (rect.width - width) * 0.5;
|
||||
}
|
||||
}
|
||||
throw 'invalid status';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns rectangle for displaying video with the origin at the left-top of the element.
|
||||
* Not considered applying CSS like `object-fit`.
|
||||
* @returns {Object}
|
||||
*/
|
||||
get contentRect() {
|
||||
const letterBoxType = this.letterBoxType;
|
||||
const letterBoxSize = this.letterBoxSize;
|
||||
|
||||
var rect = this._videoElem.getBoundingClientRect();
|
||||
|
||||
const x = letterBoxType == LetterBoxType.Vertical ? letterBoxSize : 0;
|
||||
const y = letterBoxType == LetterBoxType.Horizontal ? letterBoxSize : 0;
|
||||
const width = letterBoxType == LetterBoxType.Vertical ? rect.width - letterBoxSize * 2 : rect.width;
|
||||
const height = letterBoxType == LetterBoxType.Horizontal ? rect.height - letterBoxSize * 2 : rect.height;
|
||||
|
||||
return {x: x, y: y, width: width, height: height};
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this._contentRect = this.contentRect;
|
||||
}
|
||||
}
|
||||
200
WebApp/client/src/renderstreaming.js
Normal file
200
WebApp/client/src/renderstreaming.js
Normal file
@@ -0,0 +1,200 @@
|
||||
import Peer from "./peer.js";
|
||||
import * as Logger from "./logger.js";
|
||||
|
||||
function uuid4() {
|
||||
var temp_url = URL.createObjectURL(new Blob());
|
||||
var uuid = temp_url.toString();
|
||||
URL.revokeObjectURL(temp_url);
|
||||
return uuid.split(/[:/]/g).pop().toLowerCase(); // remove prefixes
|
||||
}
|
||||
|
||||
export class RenderStreaming {
|
||||
/**
|
||||
* @param signaling signaling class
|
||||
* @param {RTCConfiguration} config
|
||||
*/
|
||||
constructor(signaling, config) {
|
||||
this._peer = null;
|
||||
this._connectionId = null;
|
||||
this.onConnect = function (connectionId) { Logger.log(`Connect peer on ${connectionId}.`); };
|
||||
this.onDisconnect = function (connectionId) { Logger.log(`Disconnect peer on ${connectionId}.`); };
|
||||
this.onGotOffer = function (connectionId) { Logger.log(`On got Offer on ${connectionId}.`); };
|
||||
this.onGotAnswer = function (connectionId) { Logger.log(`On got Answer on ${connectionId}.`); };
|
||||
this.onTrackEvent = function (data) { Logger.log(`OnTrack event peer with data:${data}`); };
|
||||
this.onAddChannel = function (data) { Logger.log(`onAddChannel event peer with data:${data}`); };
|
||||
|
||||
this._config = config;
|
||||
this._signaling = signaling;
|
||||
this._signaling.addEventListener('connect', this._onConnect.bind(this));
|
||||
this._signaling.addEventListener('disconnect', this._onDisconnect.bind(this));
|
||||
this._signaling.addEventListener('offer', this._onOffer.bind(this));
|
||||
this._signaling.addEventListener('answer', this._onAnswer.bind(this));
|
||||
this._signaling.addEventListener('candidate', this._onIceCandidate.bind(this));
|
||||
}
|
||||
|
||||
async _onConnect(e) {
|
||||
const data = e.detail;
|
||||
if (this._connectionId == data.connectionId) {
|
||||
this._preparePeerConnection(this._connectionId, data.polite);
|
||||
this.onConnect(data.connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
async _onDisconnect(e) {
|
||||
const data = e.detail;
|
||||
if (this._connectionId == data.connectionId) {
|
||||
this.onDisconnect(data.connectionId);
|
||||
if (this._peer) {
|
||||
this._peer.close();
|
||||
this._peer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _onOffer(e) {
|
||||
const offer = e.detail;
|
||||
if (!this._peer) {
|
||||
this._preparePeerConnection(offer.connectionId, offer.polite);
|
||||
}
|
||||
const desc = new RTCSessionDescription({ sdp: offer.sdp, type: "offer" });
|
||||
try {
|
||||
await this._peer.onGotDescription(offer.connectionId, desc);
|
||||
} catch (error) {
|
||||
Logger.warn(`Error happen on GotDescription that description.\n Message: ${error}\n RTCSdpType:${desc.type}\n sdp:${desc.sdp}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async _onAnswer(e) {
|
||||
const answer = e.detail;
|
||||
const desc = new RTCSessionDescription({ sdp: answer.sdp, type: "answer" });
|
||||
if (this._peer) {
|
||||
try {
|
||||
await this._peer.onGotDescription(answer.connectionId, desc);
|
||||
} catch (error) {
|
||||
Logger.warn(`Error happen on GotDescription that description.\n Message: ${error}\n RTCSdpType:${desc.type}\n sdp:${desc.sdp}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _onIceCandidate(e) {
|
||||
const candidate = e.detail;
|
||||
const iceCandidate = new RTCIceCandidate({ candidate: candidate.candidate, sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex });
|
||||
if (this._peer) {
|
||||
await this._peer.onGotCandidate(candidate.connectionId, iceCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* if not set argument, a generated uuid is used.
|
||||
* @param {string | null} connectionId
|
||||
*/
|
||||
async createConnection(connectionId) {
|
||||
this._connectionId = connectionId ? connectionId : uuid4();
|
||||
await this._signaling.createConnection(this._connectionId);
|
||||
}
|
||||
|
||||
async deleteConnection() {
|
||||
await this._signaling.deleteConnection(this._connectionId);
|
||||
}
|
||||
|
||||
_preparePeerConnection(connectionId, polite) {
|
||||
if (this._peer) {
|
||||
Logger.log('Close current PeerConnection');
|
||||
this._peer.close();
|
||||
this._peer = null;
|
||||
}
|
||||
|
||||
// Create peerConnection with proxy server and set up handlers
|
||||
this._peer = new Peer(connectionId, polite, this._config);
|
||||
this._peer.addEventListener('disconnect', () => {
|
||||
this.onDisconnect(`Receive disconnect message from peer. connectionId:${connectionId}`);
|
||||
});
|
||||
this._peer.addEventListener('trackevent', (e) => {
|
||||
const data = e.detail;
|
||||
this.onTrackEvent(data);
|
||||
});
|
||||
this._peer.addEventListener('adddatachannel', (e) => {
|
||||
const data = e.detail;
|
||||
this.onAddChannel(data);
|
||||
});
|
||||
this._peer.addEventListener('ongotoffer', (e) => {
|
||||
const id = e.detail.connectionId;
|
||||
this.onGotOffer(id);
|
||||
});
|
||||
this._peer.addEventListener('ongotanswer', (e) => {
|
||||
const id = e.detail.connectionId;
|
||||
this.onGotAnswer(id);
|
||||
});
|
||||
this._peer.addEventListener('sendoffer', (e) => {
|
||||
const offer = e.detail;
|
||||
this._signaling.sendOffer(offer.connectionId, offer.sdp);
|
||||
});
|
||||
this._peer.addEventListener('sendanswer', (e) => {
|
||||
const answer = e.detail;
|
||||
this._signaling.sendAnswer(answer.connectionId, answer.sdp);
|
||||
});
|
||||
this._peer.addEventListener('sendcandidate', (e) => {
|
||||
const candidate = e.detail;
|
||||
this._signaling.sendCandidate(candidate.connectionId, candidate.candidate, candidate.sdpMid, candidate.sdpMLineIndex);
|
||||
});
|
||||
return this._peer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<RTCStatsReport> | null}
|
||||
*/
|
||||
async getStats() {
|
||||
return await this._peer.getStats(this._connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @returns {RTCDataChannel | null}
|
||||
*/
|
||||
createDataChannel(label) {
|
||||
return this._peer.createDataChannel(this._connectionId, label);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MediaStreamTrack} track
|
||||
* @returns {RTCRtpSender | null}
|
||||
*/
|
||||
addTrack(track) {
|
||||
return this._peer.addTrack(this._connectionId, track);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MediaStreamTrack | string} trackOrKind
|
||||
* @param {RTCRtpTransceiverInit | null} init
|
||||
* @returns {RTCRtpTransceiver | null}
|
||||
*/
|
||||
addTransceiver(trackOrKind, init) {
|
||||
return this._peer.addTransceiver(this._connectionId, trackOrKind, init);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @returns {RTCRtpTransceiver[] | null}
|
||||
*/
|
||||
getTransceivers() {
|
||||
return this._peer.getTransceivers(this._connectionId);
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this._signaling.start();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this._peer) {
|
||||
this._peer.close();
|
||||
this._peer = null;
|
||||
}
|
||||
|
||||
if (this._signaling) {
|
||||
await this._signaling.stop();
|
||||
this._signaling = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
WebApp/client/src/sender.js
Normal file
208
WebApp/client/src/sender.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
Mouse,
|
||||
Keyboard,
|
||||
Gamepad,
|
||||
Touchscreen,
|
||||
StateEvent,
|
||||
TextEvent
|
||||
} from "./inputdevice.js";
|
||||
|
||||
import { LocalInputManager } from "./inputremoting.js";
|
||||
import { GamepadHandler } from "./gamepadhandler.js";
|
||||
import { PointerCorrector } from "./pointercorrect.js";
|
||||
|
||||
export class Sender extends LocalInputManager {
|
||||
constructor(elem) {
|
||||
super();
|
||||
this._devices = [];
|
||||
this._elem = elem;
|
||||
this._corrector = new PointerCorrector(
|
||||
this._elem.videoWidth,
|
||||
this._elem.videoHeight,
|
||||
this._elem
|
||||
);
|
||||
|
||||
//since line 27 cannot complete resize initialization but can only monitor div dimension changes, line 26 needs to be reserved
|
||||
this._elem.addEventListener('resize', this._onResizeEvent.bind(this), false);
|
||||
const observer = new ResizeObserver(this._onResizeEvent.bind(this));
|
||||
observer.observe(this._elem);
|
||||
}
|
||||
|
||||
addMouse() {
|
||||
const descriptionMouse = {
|
||||
m_InterfaceName: "RawInput",
|
||||
m_DeviceClass: "Mouse",
|
||||
m_Manufacturer: "",
|
||||
m_Product: "",
|
||||
m_Serial: "",
|
||||
m_Version: "",
|
||||
m_Capabilities: ""
|
||||
};
|
||||
this.mouse = new Mouse("Mouse", "Mouse", 1, null, descriptionMouse);
|
||||
this._devices.push(this.mouse);
|
||||
|
||||
this._elem.addEventListener('click', this._onMouseEvent.bind(this), false);
|
||||
this._elem.addEventListener('mousedown', this._onMouseEvent.bind(this), false);
|
||||
this._elem.addEventListener('mouseup', this._onMouseEvent.bind(this), false);
|
||||
this._elem.addEventListener('mousemove', this._onMouseEvent.bind(this), false);
|
||||
this._elem.addEventListener('wheel', this._onWheelEvent.bind(this), false);
|
||||
}
|
||||
|
||||
addKeyboard() {
|
||||
const descriptionKeyboard = {
|
||||
m_InterfaceName: "RawInput",
|
||||
m_DeviceClass: "Keyboard",
|
||||
m_Manufacturer: "",
|
||||
m_Product: "",
|
||||
m_Serial: "",
|
||||
m_Version: "",
|
||||
m_Capabilities: ""
|
||||
};
|
||||
this.keyboard = new Keyboard("Keyboard", "Keyboard", 2, null, descriptionKeyboard);
|
||||
this._devices.push(this.keyboard);
|
||||
|
||||
document.addEventListener('keyup', this._onKeyEvent.bind(this), false);
|
||||
document.addEventListener('keydown', this._onKeyEvent.bind(this), false);
|
||||
}
|
||||
|
||||
addGamepad() {
|
||||
const descriptionGamepad = {
|
||||
m_InterfaceName: "RawInput",
|
||||
m_DeviceClass: "Gamepad",
|
||||
m_Manufacturer: "",
|
||||
m_Product: "",
|
||||
m_Serial: "",
|
||||
m_Version: "",
|
||||
m_Capabilities: ""
|
||||
};
|
||||
this.gamepad = new Gamepad("Gamepad", "Gamepad", 3, null, descriptionGamepad);
|
||||
this._devices.push(this.gamepad);
|
||||
|
||||
window.addEventListener("gamepadconnected", this._onGamepadEvent.bind(this), false);
|
||||
window.addEventListener("gamepaddisconnected", this._onGamepadEvent.bind(this), false);
|
||||
this._gamepadHandler = new GamepadHandler();
|
||||
this._gamepadHandler.addEventListener("gamepadupdated", this._onGamepadEvent.bind(this), false);
|
||||
}
|
||||
|
||||
addTouchscreen() {
|
||||
const descriptionTouch = {
|
||||
m_InterfaceName: "RawInput",
|
||||
m_DeviceClass: "Touch",
|
||||
m_Manufacturer: "",
|
||||
m_Product: "",
|
||||
m_Serial: "",
|
||||
m_Version: "",
|
||||
m_Capabilities: ""
|
||||
};
|
||||
this.touchscreen = new Touchscreen("Touchscreen", "Touchscreen", 4, null, descriptionTouch);
|
||||
this._devices.push(this.touchscreen);
|
||||
|
||||
this._elem.addEventListener('touchend', this._onTouchEvent.bind(this), false);
|
||||
this._elem.addEventListener('touchstart', this._onTouchEvent.bind(this), false);
|
||||
this._elem.addEventListener('touchcancel', this._onTouchEvent.bind(this), false);
|
||||
this._elem.addEventListener('touchmove', this._onTouchEvent.bind(this), false);
|
||||
this._elem.addEventListener('click', this._onTouchEvent.bind(this), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {InputDevice[]}
|
||||
*/
|
||||
get devices() {
|
||||
return this._devices;
|
||||
}
|
||||
|
||||
_onResizeEvent() {
|
||||
this._corrector.reset(
|
||||
this._elem.videoWidth,
|
||||
this._elem.videoHeight,
|
||||
this._elem
|
||||
);
|
||||
}
|
||||
_onMouseEvent(event) {
|
||||
this.mouse.queueEvent(event);
|
||||
this.mouse.currentState.position = this._corrector.map(this.mouse.currentState.position);
|
||||
this._queueStateEvent(this.mouse.currentState, this.mouse);
|
||||
}
|
||||
_onWheelEvent(event) {
|
||||
this.mouse.queueEvent(event);
|
||||
this._queueStateEvent(this.mouse.currentState, this.mouse);
|
||||
}
|
||||
_onKeyEvent(event) {
|
||||
if(event.type == 'keydown') {
|
||||
if(!event.repeat) { // StateEvent
|
||||
this.keyboard.queueEvent(event);
|
||||
this._queueStateEvent(this.keyboard.currentState, this.keyboard);
|
||||
}
|
||||
// TextEvent
|
||||
this._queueTextEvent(this.keyboard, event);
|
||||
}
|
||||
else if(event.type == 'keyup') {
|
||||
this.keyboard.queueEvent(event);
|
||||
this._queueStateEvent(this.keyboard.currentState, this.keyboard);
|
||||
}
|
||||
}
|
||||
_onTouchEvent(event) {
|
||||
this.touchscreen.queueEvent(event, this.timeSinceStartup);
|
||||
for(let touch of this.touchscreen.currentState.touchData) {
|
||||
let clone = touch.copy();
|
||||
clone.position = this._corrector.map(clone.position);
|
||||
this._queueStateEvent(clone, this.touchscreen);
|
||||
}
|
||||
}
|
||||
_onGamepadEvent(event) {
|
||||
switch(event.type) {
|
||||
case 'gamepadconnected': {
|
||||
this._gamepadHandler.addGamepad(event.gamepad);
|
||||
break;
|
||||
}
|
||||
case 'gamepaddisconnected': {
|
||||
this._gamepadHandler.removeGamepad(event.gamepad);
|
||||
break;
|
||||
}
|
||||
case 'gamepadupdated': {
|
||||
this.gamepad.queueEvent(event);
|
||||
this._queueStateEvent(this.gamepad.currentState, this.gamepad);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_queueStateEvent(state, device) {
|
||||
const stateEvent =
|
||||
StateEvent.fromState(state, device.deviceId, this.timeSinceStartup);
|
||||
const e = new CustomEvent(
|
||||
'event', {detail: { event: stateEvent, device: device}});
|
||||
super.onEvent.dispatchEvent(e);
|
||||
}
|
||||
_queueTextEvent(device, event) {
|
||||
const textEvent = TextEvent.create(device.deviceId, event, this.timeSinceStartup);
|
||||
const e = new CustomEvent(
|
||||
'event', {detail: { event: textEvent, device: device}});
|
||||
super.onEvent.dispatchEvent(e);
|
||||
}
|
||||
_queueDeviceChange(device, usage) {
|
||||
const e = new CustomEvent(
|
||||
'changedeviceusage', {detail: { device: device, usage: usage }});
|
||||
super.onEvent.dispatchEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
export class Observer {
|
||||
/**
|
||||
*
|
||||
* @param {RTCDataChannel} channel
|
||||
*/
|
||||
constructor(channel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {Message} message
|
||||
*/
|
||||
onNext(message) {
|
||||
if(this.channel == null || this.channel.readyState != 'open') {
|
||||
return;
|
||||
}
|
||||
this.channel.send(message.buffer);
|
||||
}
|
||||
}
|
||||
239
WebApp/client/src/signaling.js
Normal file
239
WebApp/client/src/signaling.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import * as Logger from "./logger.js";
|
||||
|
||||
export class Signaling extends EventTarget {
|
||||
|
||||
constructor(interval = 1000) {
|
||||
super();
|
||||
this.running = false;
|
||||
this.interval = interval;
|
||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||
}
|
||||
|
||||
headers() {
|
||||
if (this.sessionId !== undefined) {
|
||||
return { 'Content-Type': 'application/json', 'Session-Id': this.sessionId };
|
||||
}
|
||||
else {
|
||||
return { 'Content-Type': 'application/json' };
|
||||
}
|
||||
}
|
||||
|
||||
url(method, parameter='') {
|
||||
let ret = location.origin + '/signaling';
|
||||
if(method)
|
||||
ret += '/' + method;
|
||||
if(parameter)
|
||||
ret += '?' + parameter;
|
||||
return ret;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if(this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
while (!this.sessionId) {
|
||||
const createResponse = await fetch(this.url(''), { method: 'PUT', headers: this.headers() });
|
||||
const session = await createResponse.json();
|
||||
this.sessionId = session.sessionId;
|
||||
|
||||
if (!this.sessionId) {
|
||||
await this.sleep(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
this.loopGetAll();
|
||||
}
|
||||
|
||||
async loopGetAll() {
|
||||
let lastTimeRequest = Date.now() - 30000;
|
||||
while (this.running) {
|
||||
const res = await this.getAll(lastTimeRequest);
|
||||
const data = await res.json();
|
||||
lastTimeRequest = data.datetime ? data.datetime : Date.now();
|
||||
|
||||
const messages = data.messages;
|
||||
|
||||
for(const msg of messages) {
|
||||
switch (msg.type) {
|
||||
case "connect":
|
||||
break;
|
||||
case "disconnect":
|
||||
this.dispatchEvent(new CustomEvent('disconnect', { detail: msg }));
|
||||
break;
|
||||
case "offer":
|
||||
this.dispatchEvent(new CustomEvent('offer', { detail: msg } ));
|
||||
break;
|
||||
case "answer":
|
||||
this.dispatchEvent(new CustomEvent('answer', { detail: msg } ));
|
||||
break;
|
||||
case "candidate":
|
||||
this.dispatchEvent(new CustomEvent('candidate', { detail: msg }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
await this.sleep(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.running = false;
|
||||
await fetch(this.url(''), { method: 'DELETE', headers: this.headers() });
|
||||
this.sessionId = null;
|
||||
}
|
||||
|
||||
async createConnection(connectionId) {
|
||||
const data = { 'connectionId': connectionId };
|
||||
const res = await fetch(this.url('connection'), { method: 'PUT', headers: this.headers(), body: JSON.stringify(data) });
|
||||
const json = await res.json();
|
||||
Logger.log(`Signaling: HTTP create connection, connectionId: ${json.connectionId}, polite:${json.polite}`);
|
||||
|
||||
this.dispatchEvent(new CustomEvent('connect', { detail: json }));
|
||||
return json;
|
||||
}
|
||||
|
||||
async deleteConnection(connectionId) {
|
||||
const data = { 'connectionId': connectionId };
|
||||
const res = await fetch(this.url('connection'), { method: 'DELETE', headers: this.headers(), body: JSON.stringify(data) });
|
||||
const json = await res.json();
|
||||
this.dispatchEvent(new CustomEvent('disconnect', { detail: json }));
|
||||
return json;
|
||||
}
|
||||
|
||||
async sendOffer(connectionId, sdp) {
|
||||
const data = { 'sdp': sdp, 'connectionId': connectionId };
|
||||
Logger.log('sendOffer:' + data);
|
||||
await fetch(this.url('offer'), { method: 'POST', headers: this.headers(), body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async sendAnswer(connectionId, sdp) {
|
||||
const data = { 'sdp': sdp, 'connectionId': connectionId };
|
||||
Logger.log('sendAnswer:' + data);
|
||||
await fetch(this.url('answer'), { method: 'POST', headers: this.headers(), body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async sendCandidate(connectionId, candidate, sdpMid, sdpMLineIndex) {
|
||||
const data = {
|
||||
'candidate': candidate,
|
||||
'sdpMLineIndex': sdpMLineIndex,
|
||||
'sdpMid': sdpMid,
|
||||
'connectionId': connectionId
|
||||
};
|
||||
Logger.log('sendCandidate:' + data);
|
||||
await fetch(this.url('candidate'), { method: 'POST', headers: this.headers(), body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async getAll(fromTime = 0) {
|
||||
return await fetch(this.url(``, `fromtime=${fromTime}`), { method: 'GET', headers: this.headers() });
|
||||
}
|
||||
}
|
||||
|
||||
export class WebSocketSignaling extends EventTarget {
|
||||
|
||||
constructor(interval = 1000) {
|
||||
super();
|
||||
this.interval = interval;
|
||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||
|
||||
let websocketUrl;
|
||||
if (location.protocol === "https:") {
|
||||
websocketUrl = "wss://" + location.host;
|
||||
} else {
|
||||
websocketUrl = "ws://" + location.host;
|
||||
}
|
||||
|
||||
this.websocket = new WebSocket(websocketUrl);
|
||||
this.connectionId = null;
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
this.isWsOpen = true;
|
||||
};
|
||||
|
||||
this.websocket.onclose = () => {
|
||||
this.isWsOpen = false;
|
||||
};
|
||||
|
||||
this.websocket.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (!msg || !this) {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.log(msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case "connect":
|
||||
this.dispatchEvent(new CustomEvent('connect', { detail: msg }));
|
||||
break;
|
||||
case "disconnect":
|
||||
this.dispatchEvent(new CustomEvent('disconnect', { detail: msg }));
|
||||
break;
|
||||
case "offer":
|
||||
this.dispatchEvent(new CustomEvent('offer', { detail: { connectionId: msg.from, sdp: msg.data.sdp, polite: msg.data.polite } }));
|
||||
break;
|
||||
case "answer":
|
||||
this.dispatchEvent(new CustomEvent('answer', { detail: { connectionId: msg.from, sdp: msg.data.sdp } }));
|
||||
break;
|
||||
case "candidate":
|
||||
this.dispatchEvent(new CustomEvent('candidate', { detail: { connectionId: msg.from, candidate: msg.data.candidate, sdpMLineIndex: msg.data.sdpMLineIndex, sdpMid: msg.data.sdpMid } }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async start() {
|
||||
while (!this.isWsOpen) {
|
||||
await this.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.websocket.close();
|
||||
while (this.isWsOpen) {
|
||||
await this.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
createConnection(connectionId) {
|
||||
const sendJson = JSON.stringify({ type: "connect", connectionId: connectionId });
|
||||
Logger.log(sendJson);
|
||||
this.websocket.send(sendJson);
|
||||
}
|
||||
|
||||
deleteConnection(connectionId) {
|
||||
const sendJson = JSON.stringify({ type: "disconnect", connectionId: connectionId });
|
||||
Logger.log(sendJson);
|
||||
this.websocket.send(sendJson);
|
||||
}
|
||||
|
||||
sendOffer(connectionId, sdp) {
|
||||
const data = { 'sdp': sdp, 'connectionId': connectionId };
|
||||
const sendJson = JSON.stringify({ type: "offer", from: connectionId, data: data });
|
||||
Logger.log(sendJson);
|
||||
this.websocket.send(sendJson);
|
||||
}
|
||||
|
||||
sendAnswer(connectionId, sdp) {
|
||||
const data = { 'sdp': sdp, 'connectionId': connectionId };
|
||||
const sendJson = JSON.stringify({ type: "answer", from: connectionId, data: data });
|
||||
Logger.log(sendJson);
|
||||
this.websocket.send(sendJson);
|
||||
}
|
||||
|
||||
sendCandidate(connectionId, candidate, sdpMLineIndex, sdpMid) {
|
||||
const data = {
|
||||
'candidate': candidate,
|
||||
'sdpMLineIndex': sdpMLineIndex,
|
||||
'sdpMid': sdpMid,
|
||||
'connectionId': connectionId
|
||||
};
|
||||
const sendJson = JSON.stringify({ type: "candidate", from: connectionId, data: data });
|
||||
Logger.log(sendJson);
|
||||
this.websocket.send(sendJson);
|
||||
}
|
||||
}
|
||||
7
WebApp/client/src/touchflags.js
Normal file
7
WebApp/client/src/touchflags.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const TouchFlags =
|
||||
{
|
||||
IndirectTouch: 1 << 0,
|
||||
PrimaryTouch: 1 << 4,
|
||||
Tap: 1 << 5,
|
||||
OrphanedPrimaryTouch: 1 << 6,
|
||||
};
|
||||
8
WebApp/client/src/touchphase.js
Normal file
8
WebApp/client/src/touchphase.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export const TouchPhase = {
|
||||
None: 0,
|
||||
Began: 1,
|
||||
Moved: 2,
|
||||
Ended: 3,
|
||||
Canceled: 4,
|
||||
Stationary: 5
|
||||
};
|
||||
16
WebApp/client/test/domrect.js
Normal file
16
WebApp/client/test/domrect.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// mock class
|
||||
|
||||
export class DOMRect {
|
||||
constructor(x, y, width, height) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
get left() {
|
||||
return this.x;
|
||||
}
|
||||
get top() {
|
||||
return this.y;
|
||||
}
|
||||
}
|
||||
11
WebApp/client/test/domvideoelement.js
Normal file
11
WebApp/client/test/domvideoelement.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// mock class
|
||||
|
||||
export class DOMHTMLVideoElement {
|
||||
constructor(rect) {
|
||||
this.rect = rect;
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return this.rect;
|
||||
}
|
||||
}
|
||||
172
WebApp/client/test/inputdevice.test.js
Normal file
172
WebApp/client/test/inputdevice.test.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
FourCC,
|
||||
Mouse,
|
||||
Keyboard,
|
||||
Touchscreen,
|
||||
Gamepad,
|
||||
KeyboardState,
|
||||
MouseState,
|
||||
TouchscreenState,
|
||||
GamepadState,
|
||||
StateEvent,
|
||||
InputEvent,
|
||||
TextEvent
|
||||
} from "../src/inputdevice.js";
|
||||
|
||||
describe(`FourCC`, () => {
|
||||
test('toInt32', () => {
|
||||
const number = new FourCC('A', 'A', 'A', 'A').toInt32();
|
||||
expect(number).toBe(0x41414141);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`MouseState`, () => {
|
||||
describe(`with MouseEvent`, () => {
|
||||
let event;
|
||||
beforeEach(() => {
|
||||
event = new MouseEvent('click', { buttons:1, clientX:0, clientY:0});
|
||||
});
|
||||
test('format', () => {
|
||||
const format = new MouseState(event).format;
|
||||
expect(format).toBe(0x4d4f5553);
|
||||
});
|
||||
test('buffer', () => {
|
||||
const state = new MouseState(event);
|
||||
expect(state.buffer.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
describe(`with WheelEvent`, () => {
|
||||
let event;
|
||||
beforeEach(() => {
|
||||
event = new WheelEvent('wheel', { deltaX:0, deltaY:0 });
|
||||
});
|
||||
test('format', () => {
|
||||
const format = new MouseState(event).format;
|
||||
expect(format).toBe(0x4d4f5553);
|
||||
});
|
||||
test('buffer', () => {
|
||||
const state = new MouseState(event);
|
||||
expect(state.buffer.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`KeyboardState`, () => {
|
||||
let event;
|
||||
beforeEach(() => {
|
||||
event = new KeyboardEvent('keydown', { code: 'KeyA' });
|
||||
});
|
||||
test('format', () => {
|
||||
const format = new KeyboardState(event).format;
|
||||
expect(format).toBe(0x4b455953);
|
||||
});
|
||||
test('buffer', () => {
|
||||
const state = new KeyboardState(event);
|
||||
expect(state.buffer.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`TouchscreenState`, () => {
|
||||
let event;
|
||||
beforeEach(() => {
|
||||
event = new TouchEvent("touchstart", {
|
||||
changedTouches: [{ // InputInit
|
||||
identifier: 0,
|
||||
target: null,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
radiusX: 0,
|
||||
radiusY: 0,
|
||||
rotationAngle: 0,
|
||||
force: 0,
|
||||
altitudeAngle: 0,
|
||||
azimuthAngle:0,
|
||||
touchType: "direct"
|
||||
}]
|
||||
});
|
||||
});
|
||||
test('format', () => {
|
||||
const format = new TouchscreenState(event, null, Date.now()).format;
|
||||
expect(format).toBe(0x54534352);
|
||||
});
|
||||
test('buffer', () => {
|
||||
const state = new TouchscreenState(event, null, Date.now());
|
||||
expect(state.buffer.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`GamepadState`, () => {
|
||||
let event;
|
||||
beforeEach(() => {
|
||||
event = {
|
||||
type: 'gamepadupdated',
|
||||
gamepad : {
|
||||
id: 1,
|
||||
buttons: Array(16).fill({ pressed: false, value: 1 }),
|
||||
axes:[0, 0, 0, 0]
|
||||
}};
|
||||
});
|
||||
test('format', () => {
|
||||
const format = new GamepadState(event).format;
|
||||
expect(format).toBe(0x47504144);
|
||||
});
|
||||
test('buffer', () => {
|
||||
const state = new GamepadState(event);
|
||||
expect(state.buffer.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`StateEvent`, () => {
|
||||
let state;
|
||||
beforeEach(() => {
|
||||
const event = new KeyboardEvent('keydown', { code: 'KeyA' });
|
||||
state = new KeyboardState(event);
|
||||
});
|
||||
test('buffer', () => {
|
||||
const stateEvent = StateEvent.fromState(state, 0, Date.now());
|
||||
expect(new Int32Array(stateEvent.buffer.slice(0, 4))[0]).toBe(StateEvent.format);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`TextEvent`, () => {
|
||||
test('buffer', () => {
|
||||
const event = new KeyboardEvent('keydown', { code: 'KeyA', key: "a"});
|
||||
const textEvent = TextEvent.create(0, event, Date.now());
|
||||
expect(new Int32Array(textEvent.buffer.slice(0, 4))[0]).toBe(TextEvent.format);
|
||||
const offset = InputEvent.size;
|
||||
// 'a' is 97
|
||||
expect(new Uint32Array(textEvent.buffer.slice(offset, offset+4))[0]).toBe(97);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`Mouse`, () => {
|
||||
test('alignedSizeInBytes', () => {
|
||||
let device = new Mouse("Mouse", "Mouse", 1, null, null);
|
||||
expect(device).toBeInstanceOf(Mouse);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`Keyboard`, () => {
|
||||
test('alignedSizeInBytes', () => {
|
||||
let device = new Keyboard("Keyboard", "Keyboard", 1, null, null);
|
||||
expect(device).toBeInstanceOf(Keyboard);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`Touchscreen`, () => {
|
||||
test('alignedSizeInBytes', () => {
|
||||
let device = new Touchscreen("Touchscreen", "Touchscreen", 1, null, null);
|
||||
expect(device).toBeInstanceOf(Touchscreen);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`Gamepad`, () => {
|
||||
test('alignedSizeInBytes', () => {
|
||||
let device = new Gamepad("Gamepad", "Gamepad", 1, null, null);
|
||||
expect(device).toBeInstanceOf(Gamepad);
|
||||
});
|
||||
});
|
||||
132
WebApp/client/test/inputremoting.test.js
Normal file
132
WebApp/client/test/inputremoting.test.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
InputDevice,
|
||||
MouseState,
|
||||
KeyboardState,
|
||||
TouchscreenState,
|
||||
GamepadState
|
||||
} from "../src/inputdevice.js";
|
||||
|
||||
import {
|
||||
MessageType,
|
||||
NewDeviceMsg,
|
||||
NewEventsMsg,
|
||||
RemoveDeviceMsg,
|
||||
InputRemoting,
|
||||
} from "../src/inputremoting.js";
|
||||
|
||||
import {
|
||||
Sender,
|
||||
Observer
|
||||
} from "../src/sender.js";
|
||||
|
||||
import {DOMRect} from "./domrect.js";
|
||||
|
||||
describe(`InputRemoting`, () => {
|
||||
let sender = null;
|
||||
let inputRemoting = null;
|
||||
let observer = null;
|
||||
beforeEach(async () => {
|
||||
document.getBoundingClientRect = function(){ return new DOMRect(0,0,0,0); };
|
||||
sender = new Sender(document);
|
||||
inputRemoting = new InputRemoting(sender);
|
||||
let dc = null;
|
||||
observer = new Observer(dc);
|
||||
});
|
||||
test('startSending', () => {
|
||||
expect.assertions(0);
|
||||
inputRemoting.startSending();
|
||||
});
|
||||
test('stopSending', () => {
|
||||
expect.assertions(0);
|
||||
inputRemoting.startSending();
|
||||
inputRemoting.stopSending();
|
||||
});
|
||||
test('subscribe', () => {
|
||||
expect.assertions(0);
|
||||
inputRemoting.subscribe(observer);
|
||||
});
|
||||
});
|
||||
|
||||
test('create NewDeviceMsg', () => {
|
||||
const device = new InputDevice("Keyboard", "Keyboard", 0, null, null);
|
||||
const msg = NewDeviceMsg.create(device);
|
||||
expect(msg.participant_id).toBe(0);
|
||||
expect(msg.type).toBe(MessageType.NewDevice);
|
||||
expect(msg.data).toBeInstanceOf(ArrayBuffer);
|
||||
expect(msg.data.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe('create NewEventMsg', () => {
|
||||
test('using MouseState', () => {
|
||||
const event = new MouseEvent('click', { buttons:0, clientX:0, clientY:0} );
|
||||
const state = new MouseState(event);
|
||||
const msg = NewEventsMsg.create(state);
|
||||
expect(msg.participant_id).toBe(0);
|
||||
expect(msg.type).toBe(MessageType.NewEvents);
|
||||
expect(msg.data).toBeInstanceOf(ArrayBuffer);
|
||||
expect(msg.data.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
test('using KeyboardState', () => {
|
||||
const event = new KeyboardEvent("keydown", { code: 'KeyA' });
|
||||
const state = new KeyboardState(event);
|
||||
const msg = NewEventsMsg.create(state);
|
||||
expect(msg.participant_id).toBe(0);
|
||||
expect(msg.type).toBe(MessageType.NewEvents);
|
||||
expect(msg.data).toBeInstanceOf(ArrayBuffer);
|
||||
expect(msg.data.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
test('using TouchscreenState', () => {
|
||||
const event = new TouchEvent("touchstart", {
|
||||
changedTouches: [{ // InputInit
|
||||
identifier: 0,
|
||||
target: null,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
radiusX: 0,
|
||||
radiusY: 0,
|
||||
rotationAngle: 0,
|
||||
force: 0,
|
||||
altitudeAngle: 0,
|
||||
azimuthAngle:0,
|
||||
touchType: "direct"
|
||||
}]
|
||||
});
|
||||
const state = new TouchscreenState(event, null, Date.now());
|
||||
expect(state.touchData).not.toBeNull();
|
||||
expect(state.touchData).toHaveLength(1);
|
||||
const msg = NewEventsMsg.create(state.touchData[0]);
|
||||
expect(msg.participant_id).toBe(0);
|
||||
expect(msg.type).toBe(MessageType.NewEvents);
|
||||
expect(msg.data).toBeInstanceOf(ArrayBuffer);
|
||||
expect(msg.data.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
test('using GamepadState', () => {
|
||||
const event = {
|
||||
type: 'gamepadupdated',
|
||||
gamepad : {
|
||||
id: 1,
|
||||
buttons: Array(16).fill({ pressed: false, value: 1 }),
|
||||
axes:[1, 1, 1, 1]
|
||||
}};
|
||||
const state = new GamepadState(event);
|
||||
const msg = NewEventsMsg.create(state);
|
||||
expect(msg.participant_id).toBe(0);
|
||||
expect(msg.type).toBe(MessageType.NewEvents);
|
||||
expect(msg.data).toBeInstanceOf(ArrayBuffer);
|
||||
expect(msg.data.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('create RemoveDeviceMsg', () => {
|
||||
const device = new InputDevice("Keyboard", "Keyboard", 0, null, null);
|
||||
const msg = RemoveDeviceMsg.create(device);
|
||||
expect(msg.participant_id).toBe(0);
|
||||
expect(msg.type).toBe(MessageType.RemoveDevice);
|
||||
expect(msg.data).toBeInstanceOf(ArrayBuffer);
|
||||
expect(msg.data.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
67
WebApp/client/test/memoryhelper.test.js
Normal file
67
WebApp/client/test/memoryhelper.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
MemoryHelper
|
||||
} from "../src/memoryhelper.js";
|
||||
|
||||
describe(`MemoryHelper.writeSingleBit`, () => {
|
||||
test('turn on with offset 0', () => {
|
||||
let bytes = new ArrayBuffer(3);
|
||||
MemoryHelper.writeSingleBit(bytes, 0, false);
|
||||
|
||||
// check 00 00 00
|
||||
const view = new Uint8Array(bytes);
|
||||
expect(view[0]).toBe(0);
|
||||
expect(view[1]).toBe(0);
|
||||
expect(view[2]).toBe(0);
|
||||
});
|
||||
test('turn off with offset 0', () => {
|
||||
let bytes = new ArrayBuffer(3);
|
||||
MemoryHelper.writeSingleBit(bytes, 0, true);
|
||||
|
||||
// check 00 00 01
|
||||
const view = new Uint8Array(bytes);
|
||||
expect(view[0]).toBe(1);
|
||||
expect(view[1]).toBe(0);
|
||||
expect(view[2]).toBe(0);
|
||||
|
||||
MemoryHelper.writeSingleBit(bytes, 0, false);
|
||||
|
||||
// check 00 00 00
|
||||
expect(view[0]).toBe(0);
|
||||
expect(view[1]).toBe(0);
|
||||
expect(view[2]).toBe(0);
|
||||
});
|
||||
test('turn on with offset 32', () => {
|
||||
let bytes = new ArrayBuffer(3);
|
||||
MemoryHelper.writeSingleBit(bytes, 8, true);
|
||||
|
||||
// check 00 01 00
|
||||
const view = new Uint8Array(bytes);
|
||||
expect(view[0]).toBe(0);
|
||||
expect(view[1]).toBe(1);
|
||||
expect(view[2]).toBe(0);
|
||||
|
||||
MemoryHelper.writeSingleBit(bytes, 0, true);
|
||||
|
||||
// check 00 01 01
|
||||
expect(view[0]).toBe(1);
|
||||
expect(view[1]).toBe(1);
|
||||
expect(view[2]).toBe(0);
|
||||
});
|
||||
test('turn on with offset 15', () => {
|
||||
let bytes = new ArrayBuffer(3);
|
||||
MemoryHelper.writeSingleBit(bytes, 15, true);
|
||||
|
||||
// check 00 80 00
|
||||
const view = new Uint8Array(bytes);
|
||||
expect(view[0]).toBe(0);
|
||||
expect(view[1]).toBe(128);
|
||||
expect(view[2]).toBe(0);
|
||||
|
||||
MemoryHelper.writeSingleBit(bytes, 15, false);
|
||||
|
||||
// check 00 00 00
|
||||
expect(view[0]).toBe(0);
|
||||
expect(view[1]).toBe(0);
|
||||
expect(view[2]).toBe(0);
|
||||
});
|
||||
});
|
||||
224
WebApp/client/test/mocksignaling.js
Normal file
224
WebApp/client/test/mocksignaling.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { sleep } from "./testutils";
|
||||
|
||||
/** @type {MockPrivateSignalingManager | MockPublicSignalingManager} */
|
||||
let manager;
|
||||
|
||||
export function reset(isPrivate) {
|
||||
manager = isPrivate ? new MockPrivateSignalingManager() : new MockPublicSignalingManager();
|
||||
}
|
||||
|
||||
export class MockSignaling extends EventTarget {
|
||||
|
||||
constructor(interval = 1000) {
|
||||
super();
|
||||
this.interval = interval;
|
||||
}
|
||||
|
||||
async start() {
|
||||
await manager.add(this);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await manager.remove(this);
|
||||
}
|
||||
|
||||
async createConnection(connectionId) {
|
||||
await manager.openConnection(this, connectionId);
|
||||
}
|
||||
|
||||
async deleteConnection(connectionId) {
|
||||
await manager.closeConnection(this, connectionId);
|
||||
}
|
||||
|
||||
async sendOffer(connectionId, sdp) {
|
||||
const data = { 'sdp': sdp, 'connectionId': connectionId };
|
||||
await manager.offer(this, data);
|
||||
}
|
||||
|
||||
async sendAnswer(connectionId, sdp) {
|
||||
const data = { 'sdp': sdp, 'connectionId': connectionId };
|
||||
await manager.answer(this, data);
|
||||
}
|
||||
|
||||
async sendCandidate(connectionId, candidate, sdpMLineIndex, sdpMid) {
|
||||
const data = {
|
||||
'candidate': candidate,
|
||||
'sdpMLineIndex': sdpMLineIndex,
|
||||
'sdpMid': sdpMid,
|
||||
'connectionId': connectionId
|
||||
};
|
||||
await manager.candidate(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
class MockPublicSignalingManager {
|
||||
constructor() {
|
||||
this.list = new Set();
|
||||
this.delay = async () => await sleep(10);
|
||||
}
|
||||
|
||||
async add(signaling) {
|
||||
await this.delay();
|
||||
this.list.add(signaling);
|
||||
signaling.dispatchEvent(new Event("start"));
|
||||
}
|
||||
|
||||
async remove(signaling) {
|
||||
await this.delay();
|
||||
this.list.delete(signaling);
|
||||
signaling.dispatchEvent(new Event("end"));
|
||||
}
|
||||
|
||||
async openConnection(signaling, connectionId) {
|
||||
await this.delay();
|
||||
const data = { connectionId: connectionId, polite: true };
|
||||
signaling.dispatchEvent(new CustomEvent("connect", { detail: data }));
|
||||
}
|
||||
|
||||
async closeConnection(signaling, connectionId) {
|
||||
await this.delay();
|
||||
const data = { connectionId: connectionId };
|
||||
for (const element of this.list) {
|
||||
element.dispatchEvent(new CustomEvent("disconnect", { detail: data }));
|
||||
}
|
||||
}
|
||||
|
||||
async offer(owner, data) {
|
||||
await this.delay();
|
||||
data.polite = false;
|
||||
for (const signaling of this.list) {
|
||||
if (signaling != owner) {
|
||||
signaling.dispatchEvent(new CustomEvent("offer", { detail: data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async answer(owner, data) {
|
||||
await this.delay();
|
||||
for (const signaling of this.list) {
|
||||
if (signaling != owner) {
|
||||
signaling.dispatchEvent(new CustomEvent("answer", { detail: data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async candidate(owner, data) {
|
||||
await this.delay();
|
||||
for (const signaling of this.list) {
|
||||
if (signaling != owner) {
|
||||
signaling.dispatchEvent(new CustomEvent("candidate", { detail: data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MockPrivateSignalingManager {
|
||||
constructor() {
|
||||
// structure Map<string:connectionId, Set<MockSignaling>> connectionIds
|
||||
this.connectionIds = new Map();
|
||||
this.delay = async () => await sleep(10);
|
||||
}
|
||||
|
||||
async add(signaling) {
|
||||
await this.delay();
|
||||
signaling.dispatchEvent(new Event("start"));
|
||||
}
|
||||
|
||||
async remove(signaling) {
|
||||
await this.delay();
|
||||
signaling.dispatchEvent(new Event("end"));
|
||||
}
|
||||
|
||||
async openConnection(signaling, connectionId) {
|
||||
await this.delay();
|
||||
const peerExists = this.connectionIds.has(connectionId);
|
||||
if (!peerExists) {
|
||||
this.connectionIds.set(connectionId, new Set());
|
||||
}
|
||||
|
||||
const list = this.connectionIds.get(connectionId);
|
||||
list.add(signaling);
|
||||
|
||||
const data = { connectionId: connectionId, polite: peerExists };
|
||||
signaling.dispatchEvent(new CustomEvent("connect", { detail: data }));
|
||||
}
|
||||
|
||||
async closeConnection(signaling, connectionId) {
|
||||
await this.delay();
|
||||
const peerExists = this.connectionIds.has(connectionId);
|
||||
const list = this.connectionIds.get(connectionId);
|
||||
if (!peerExists || !list.has(signaling)) {
|
||||
console.error(`${connectionId} This connection id is not used.`);
|
||||
}
|
||||
|
||||
const data = { connectionId: connectionId };
|
||||
for (const element of list) {
|
||||
element.dispatchEvent(new CustomEvent("disconnect", { detail: data }));
|
||||
}
|
||||
|
||||
list.delete(signaling);
|
||||
if (list.size == 0) {
|
||||
this.connectionIds.delete(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
findList(owner, connectionId) {
|
||||
if (!this.connectionIds.has(connectionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const list = new Set(this.connectionIds.get(connectionId));
|
||||
list.delete(owner);
|
||||
if (list.Count == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
async offer(owner, data) {
|
||||
await this.delay();
|
||||
const list = this.findList(owner, data.connectionId);
|
||||
if (list == null) {
|
||||
console.warn(`${data.connectionId} This connection id is not ready other session.`);
|
||||
return;
|
||||
}
|
||||
|
||||
data.polite = true;
|
||||
for (const signaling of list) {
|
||||
if (signaling != owner) {
|
||||
signaling.dispatchEvent(new CustomEvent("offer", { detail: data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async answer(owner, data) {
|
||||
await this.delay();
|
||||
const list = this.findList(owner, data.connectionId);
|
||||
if (list == null) {
|
||||
console.warn(`${data.connectionId} This connection id is not ready other session.`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const signaling of list) {
|
||||
if (signaling != owner) {
|
||||
signaling.dispatchEvent(new CustomEvent("answer", { detail: data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async candidate(owner, data) {
|
||||
await this.delay();
|
||||
const list = this.findList(owner, data.connectionId);
|
||||
if (list == null) {
|
||||
console.warn(`${data.connectionId} This connection id is not ready other session.`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const signaling of list) {
|
||||
if (signaling != owner) {
|
||||
signaling.dispatchEvent(new CustomEvent("candidate", { detail: data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
250
WebApp/client/test/peerconnection.test.js
Normal file
250
WebApp/client/test/peerconnection.test.js
Normal file
@@ -0,0 +1,250 @@
|
||||
import Peer from "../src/peer.js";
|
||||
import { waitFor, sleep, getUniqueId, getRTCConfiguration } from "./testutils.js";
|
||||
|
||||
|
||||
describe(`peer connection test`, () => {
|
||||
const connectionId = "12345";
|
||||
|
||||
test(`constructor`, () => {
|
||||
const peer = new Peer(connectionId, true);
|
||||
expect(peer).not.toBeNull();
|
||||
|
||||
const rtcPeer = peer.pc;
|
||||
expect(rtcPeer).not.toBeNull();
|
||||
expect(rtcPeer.ontrack).not.toBeNull();
|
||||
expect(rtcPeer.onicecandidate).not.toBeNull();
|
||||
expect(rtcPeer.onnegotiationneeded).not.toBeNull();
|
||||
expect(rtcPeer.onsignalingstatechange).not.toBeNull();
|
||||
expect(rtcPeer.oniceconnectionstatechange).not.toBeNull();
|
||||
expect(rtcPeer.onicegatheringstatechange).not.toBeNull();
|
||||
});
|
||||
|
||||
test(`close peer`, async () => {
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
|
||||
peer.close();
|
||||
expect(peer.connectionId).toBeNull();
|
||||
expect(peer.pc).toBeNull();
|
||||
});
|
||||
|
||||
test(`transceiver direction is sendrecv if using addtrack`, () => {
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
|
||||
const track = { id: getUniqueId(), kind: "audio" };
|
||||
const sender = peer.addTrack(connectionId, track);
|
||||
const transceiver = peer.getTransceivers(connectionId).find(t => t.sender == sender);
|
||||
expect(transceiver.direction).toBe("sendrecv");
|
||||
});
|
||||
|
||||
test(`fire trackevent when addtrack`, async () => {
|
||||
let trackEvent;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('trackevent', (e) => trackEvent = e.detail);
|
||||
|
||||
const track = { id: getUniqueId(), kind: "audio" };
|
||||
peer.addTrack(connectionId, track);
|
||||
await waitFor(() => trackEvent != null);
|
||||
expect(trackEvent.track).toBe(track);
|
||||
});
|
||||
|
||||
test(`fire trackevent when on got offer description include track`, async () => {
|
||||
let trackEvent;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('trackevent', (e) => trackEvent = e.detail);
|
||||
|
||||
const testDesc = { type: "offer", sdp: "newtracksdp" };
|
||||
peer.onGotDescription(connectionId, testDesc);
|
||||
await waitFor(() => trackEvent != null);
|
||||
expect(trackEvent.track).not.toBeNull();
|
||||
});
|
||||
|
||||
test(`fire sendoffer when addtrack`, async () => {
|
||||
let offer;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendoffer', (e) => offer = e.detail);
|
||||
|
||||
const track = { id: getUniqueId(), kind: "audio" };
|
||||
peer.addTrack(connectionId, track);
|
||||
await waitFor(() => offer != null);
|
||||
expect(offer.connectionId).toBe(connectionId);
|
||||
});
|
||||
|
||||
test(`fire sendoffer when addTransceiver`, async () => {
|
||||
let offer;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendoffer', (e) => offer = e.detail);
|
||||
|
||||
peer.addTransceiver(connectionId, "video");
|
||||
await waitFor(() => offer != null);
|
||||
expect(offer.connectionId).toBe(connectionId);
|
||||
});
|
||||
|
||||
test(`fire sendoffer when createDataChannel`, async () => {
|
||||
let offer;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendoffer', (e) => offer = e.detail);
|
||||
|
||||
peer.createDataChannel(connectionId, "testChannel");
|
||||
await waitFor(() => offer != null);
|
||||
expect(offer.connectionId).toBe(connectionId);
|
||||
});
|
||||
|
||||
test(`re-fire sendoffer if get answer not yet`, async () => {
|
||||
let sendOfferCount = 0;
|
||||
let offer;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config, 100);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendoffer', (e) => {
|
||||
offer = e.detail;
|
||||
sendOfferCount++;
|
||||
});
|
||||
|
||||
const track = { id: getUniqueId(), kind: "audio" };
|
||||
peer.addTrack(connectionId, track);
|
||||
await waitFor(() => sendOfferCount > 2);
|
||||
expect(offer.connectionId).toBe(connectionId);
|
||||
expect(sendOfferCount).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
test(`fire sendanswer when on got offer description in polite`, async () => {
|
||||
let answer;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendanswer', (e) => answer = e.detail);
|
||||
|
||||
const testDesc = { type: "offer", sdp: "newtracksdp" };
|
||||
peer.onGotDescription(connectionId, testDesc);
|
||||
await waitFor(() => answer != null);
|
||||
expect(answer.connectionId).toBe(connectionId);
|
||||
});
|
||||
|
||||
test(`fire sendanswer when on got offer description in polite that have offer`, async () => {
|
||||
let offer;
|
||||
let answer;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendoffer', (e) => offer = e.detail);
|
||||
peer.addEventListener('sendanswer', (e) => answer = e.detail);
|
||||
|
||||
const track = { id: getUniqueId(), kind: "audio" };
|
||||
peer.addTrack(connectionId, track);
|
||||
await waitFor(() => offer != null);
|
||||
expect(offer.connectionId).toBe(connectionId);
|
||||
|
||||
const testDesc = { type: "offer", sdp: "newtracksdp" };
|
||||
peer.onGotDescription(connectionId, testDesc);
|
||||
await waitFor(() => answer != null);
|
||||
expect(answer.connectionId).toBe(connectionId);
|
||||
});
|
||||
|
||||
test(`fire sendanswer when on got offer description in impolite that don't have offer`, async () => {
|
||||
let answer;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, false, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendanswer', (e) => answer = e.detail);
|
||||
|
||||
const testDesc = { type: "offer", sdp: "newtracksdp" };
|
||||
peer.onGotDescription(connectionId, testDesc);
|
||||
await waitFor(() => answer != null);
|
||||
expect(answer.connectionId).toBe(connectionId);
|
||||
});
|
||||
|
||||
test(`don't fire sendanswer when on got offer description in impolite that have offer`, async () => {
|
||||
let offer;
|
||||
let answer;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, false, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendoffer', (e) => offer = e.detail);
|
||||
peer.addEventListener('sendanswer', (e) => answer = e.detail);
|
||||
|
||||
const track = { id: getUniqueId(), kind: "audio" };
|
||||
peer.addTrack(connectionId, track);
|
||||
await waitFor(() => offer != null);
|
||||
expect(offer.connectionId).toBe(connectionId);
|
||||
|
||||
const testDesc = { type: "offer", sdp: "newtracksdp" };
|
||||
peer.onGotDescription(connectionId, testDesc);
|
||||
await sleep(100);
|
||||
expect(answer).toBeUndefined();
|
||||
});
|
||||
|
||||
test(`fire nagotiated when on got answer description that have offer`, async () => {
|
||||
let offer;
|
||||
let negotiated = false;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendoffer', (e) => offer = e.detail);
|
||||
peer.pc.addEventListener('negotiated', () => negotiated = true);
|
||||
|
||||
const track = { id: getUniqueId(), kind: "audio" };
|
||||
peer.addTrack(connectionId, track);
|
||||
await waitFor(() => offer != null);
|
||||
expect(offer.connectionId).toBe(connectionId);
|
||||
|
||||
const answerDesc = { type: "answer", sdp: "newtracksdp" };
|
||||
peer.onGotDescription(connectionId, answerDesc);
|
||||
await waitFor(() => negotiated);
|
||||
expect(negotiated).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`fire sendcandidate when on addTransceiver`, async () => {
|
||||
let candidate;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, true, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendcandidate', (e) => candidate = e.detail);
|
||||
|
||||
peer.addTransceiver(connectionId, { id: getUniqueId(), kind: "video" });
|
||||
await waitFor(() => candidate != null);
|
||||
expect(candidate.connectionId).toBe(connectionId);
|
||||
});
|
||||
|
||||
test(`accept candidate when on got candidate that have remote description`, async () => {
|
||||
let answer;
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, false, config);
|
||||
expect(peer).not.toBeNull();
|
||||
peer.addEventListener('sendanswer', (e) => answer = e.detail);
|
||||
|
||||
const testDesc = { type: "offer", sdp: "newtracksdp" };
|
||||
peer.onGotDescription(connectionId, testDesc);
|
||||
await waitFor(() => answer != null);
|
||||
expect(answer.connectionId).toBe(connectionId);
|
||||
|
||||
const testCandidate = { candidate: getUniqueId(), sdpMLineIndex: 0, sdpMid: 0 };
|
||||
peer.onGotCandidate(connectionId, testCandidate);
|
||||
await waitFor(() => peer.pc.candidates.length > 0);
|
||||
expect(peer.pc.candidates.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test(`don't accept candidate when on got candidate that don't have remote description`, async () => {
|
||||
const config = getRTCConfiguration();
|
||||
const peer = new Peer(connectionId, false, config);
|
||||
expect(peer).not.toBeNull();
|
||||
|
||||
const testCandidate = { candidate: getUniqueId(), sdpMLineIndex: 0, sdpMid: 0 };
|
||||
peer.onGotCandidate(connectionId, testCandidate);
|
||||
await sleep(100);
|
||||
expect(peer.pc.candidates.length).toBe(0);
|
||||
});
|
||||
});
|
||||
316
WebApp/client/test/peerconnectionmock.js
Normal file
316
WebApp/client/test/peerconnectionmock.js
Normal file
@@ -0,0 +1,316 @@
|
||||
import { sleep, getUniqueId } from './testutils';
|
||||
|
||||
export class PeerConnectionMock extends EventTarget {
|
||||
constructor(config) {
|
||||
super();
|
||||
this.delay = async () => await sleep(10);
|
||||
this.config = config;
|
||||
this.ontrack = undefined;
|
||||
this.ondatachannel = undefined;
|
||||
this.onicecandidate = undefined;
|
||||
this.onnegotiationneeded = undefined;
|
||||
this.onsignalingstatechange = undefined;
|
||||
this.oniceconnectionstatechange = undefined;
|
||||
this.onicegatheringstatechange = undefined;
|
||||
this.pendingLocalDescription = null;
|
||||
this.currentLocalDescription = null;
|
||||
this.pendingRemoteDescription = null;
|
||||
this.currentRemoteDescription = null;
|
||||
this.candidates = [];
|
||||
this.signalingState = "stable";
|
||||
this.iceConnectionState = "new";
|
||||
this.iceGatheringState = "new";
|
||||
this.audioTracks = new Map();
|
||||
this.videoTracks = new Map();
|
||||
this.channels = new Map();
|
||||
this.transceiverCount = 0;
|
||||
this.transceivers = new Map();
|
||||
}
|
||||
|
||||
get localDescription() {
|
||||
if (this.pendingLocalDescription) {
|
||||
return this.pendingLocalDescription;
|
||||
}
|
||||
|
||||
return this.currentLocalDescription;
|
||||
}
|
||||
|
||||
get remoteDescription() {
|
||||
if (this.pendingRemoteDescription) {
|
||||
return this.pendingRemoteDescription;
|
||||
}
|
||||
|
||||
return this.currentRemoteDescription;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ontrack = undefined;
|
||||
this.ondatachannel = undefined;
|
||||
this.onicecandidate = undefined;
|
||||
this.onnegotiationneeded = undefined;
|
||||
this.onsignalingstatechange = undefined;
|
||||
this.oniceconnectionstatechange = undefined;
|
||||
this.onicegatheringstatechange = undefined;
|
||||
this.pendingLocalDescription = null;
|
||||
this.currentLocalDescription = null;
|
||||
this.pendingRemoteDescription = null;
|
||||
this.currentRemoteDescription = null;
|
||||
this.candidates = [];
|
||||
this.signalingState = "close";
|
||||
this.iceConnectionState = "closed";
|
||||
this.audioTracks.clear();
|
||||
this.videoTracks.clear();
|
||||
this.channels.clear();
|
||||
this.transceiverCount = 0;
|
||||
this.transceivers.clear();
|
||||
}
|
||||
|
||||
fireOnNegotiationNeeded() {
|
||||
if (this.onnegotiationneeded) {
|
||||
this.onnegotiationneeded();
|
||||
}
|
||||
}
|
||||
|
||||
getTransceivers() {
|
||||
return Array.from(this.transceivers.values());
|
||||
}
|
||||
|
||||
addTrack(track) {
|
||||
if (track.kind == "audio") {
|
||||
this.audioTracks.set(track.id, track);
|
||||
} else {
|
||||
this.videoTracks.set(track.id, track);
|
||||
}
|
||||
const transceiver = { direction: "sendrecv", sender: { track: track }, receiver: null, setCodecPreferences: (codecs) => { console.log(codecs); } };
|
||||
this.transceivers.set(this.transceiverCount++, transceiver);
|
||||
this.fireOnNegotiationNeeded();
|
||||
return transceiver.sender;
|
||||
}
|
||||
|
||||
addTransceiver(trackOrKind) {
|
||||
if (typeof trackOrKind == "string") {
|
||||
const track = { id: getUniqueId(), kind: trackOrKind };
|
||||
if (track.kind == "audio") {
|
||||
this.audioTracks.set(track.id, track);
|
||||
} else {
|
||||
this.videoTracks.set(track.id, track);
|
||||
}
|
||||
const transceiver = { direction: "sendrecv", sender: { track: track }, receiver: null, setCodecPreferences: (codecs) => { console.log(codecs); } };
|
||||
this.transceivers.set(this.transceiverCount++, transceiver);
|
||||
this.fireOnNegotiationNeeded();
|
||||
return transceiver;
|
||||
}
|
||||
|
||||
if (trackOrKind.kind == "audio") {
|
||||
this.audioTracks.set(trackOrKind.id, trackOrKind);
|
||||
} else {
|
||||
this.videoTracks.set(trackOrKind.id, trackOrKind);
|
||||
}
|
||||
const transceiver = { direction: "sendrecv", sender: { track: trackOrKind }, receiver: null, setCodecPreferences: (codecs) => { console.log(codecs); } };
|
||||
this.transceivers.set(this.transceiverCount++, transceiver);
|
||||
this.fireOnNegotiationNeeded();
|
||||
return transceiver;
|
||||
}
|
||||
|
||||
createDataChannel(label) {
|
||||
const channel = { id: getUniqueId(), label: label };
|
||||
this.channels.set(channel.id, channel);
|
||||
this.fireOnNegotiationNeeded();
|
||||
return channel;
|
||||
}
|
||||
|
||||
async setLocalDescription(description = null) {
|
||||
if (description == null) {
|
||||
description = this._createSessionDescription();
|
||||
}
|
||||
await this.delay();
|
||||
this._setSessionDescription(description, false);
|
||||
}
|
||||
|
||||
async setRemoteDescription(description) {
|
||||
await this.delay();
|
||||
if (description.type == "offer" && this.signalingState == "have-local-offer") {
|
||||
this._setSessionDescription({ type: "rollback", sdp: "" }, true);
|
||||
}
|
||||
this._setSessionDescription(description, true);
|
||||
}
|
||||
|
||||
_createSessionDescription() {
|
||||
let dummySdp = "testsdp";
|
||||
if (this.videoTracks.size > 0) {
|
||||
dummySdp += "videotrack";
|
||||
}
|
||||
if (this.audioTracks.size > 0) {
|
||||
dummySdp += "audiotrack";
|
||||
}
|
||||
if (this.channels.size > 0) {
|
||||
dummySdp += "datachannel";
|
||||
}
|
||||
|
||||
if (this.signalingState == "stable" || this.signalingState == "have-local-offer" || this.signalingState == "have-remote-pranswer") {
|
||||
return { type: "offer", sdp: dummySdp };
|
||||
}
|
||||
return { type: "answer", sdp: dummySdp };
|
||||
}
|
||||
|
||||
_setSessionDescription(description, remote) {
|
||||
if (description.type == "rollback"
|
||||
&& (this.signalingState == "stable" || this.signalingState == "have-local-pranswer" || this.signalingState == "have-remote-pranswer")) {
|
||||
throw "InvalidStateError";
|
||||
}
|
||||
|
||||
if (description.type != "rollback") {
|
||||
if (remote) {
|
||||
if (description.type == "offer") {
|
||||
this.pendingRemoteDescription = description;
|
||||
this.signalingState = "have-remote-offer";
|
||||
this.onsignalingstatechange(this.signalingState);
|
||||
// if sdp contains track string, create dummy track
|
||||
if (description.sdp.includes("track")) {
|
||||
const isVideo = description.sdp.includes("video");
|
||||
const kind = isVideo ? "video" : "audio";
|
||||
this._createTrackAndTransceiver(kind);
|
||||
}
|
||||
if (description.sdp.includes("datachannel")) {
|
||||
const channel = { id: getUniqueId(), label: "dummychannel" };
|
||||
this.channels.set(channel.id, channel);
|
||||
}
|
||||
}
|
||||
if (description.type == "answer") {
|
||||
this.currentRemoteDescription = description;
|
||||
this.currentLocalDescription = this.pendingLocalDescription;
|
||||
this.pendingLocalDescription = null;
|
||||
this.pendingRemoteDescription = null;
|
||||
this.signalingState = "stable";
|
||||
this.onsignalingstatechange(this.signalingState);
|
||||
}
|
||||
if (description.type == "pranswer") {
|
||||
this.pendingRemoteDescription = description;
|
||||
this.signalingState = "have-remote-pranswer";
|
||||
this.onsignalingstatechange(this.signalingState);
|
||||
}
|
||||
} else {
|
||||
if (description.type == "offer") {
|
||||
this.pendingLocalDescription = description;
|
||||
this.signalingState = "have-local-offer";
|
||||
this.onsignalingstatechange(this.signalingState);
|
||||
}
|
||||
if (description.type == "answer") {
|
||||
this.currentLocalDescription = description;
|
||||
this.currentRemoteDescription = this.pendingRemoteDescription;
|
||||
this.pendingLocalDescription = null;
|
||||
this.pendingRemoteDescription = null;
|
||||
this.signalingState = "stable";
|
||||
this.onsignalingstatechange(this.signalingState);
|
||||
// if sdp contains track string, create dummy track
|
||||
if (description.sdp.includes("track")) {
|
||||
const isVideo = description.sdp.includes("video");
|
||||
const kind = isVideo ? "video" : "audio";
|
||||
this._createTrackAndTransceiver(kind);
|
||||
}
|
||||
if (description.sdp.includes("datachannel")) {
|
||||
const channel = { id: getUniqueId(), label: "dummychannel" };
|
||||
this.channels.set(channel.id, channel);
|
||||
}
|
||||
}
|
||||
if (description.type == "pranswer") {
|
||||
this.pendingLocalDescription = description;
|
||||
this.signalingState = "have-local-pranswer";
|
||||
this.onsignalingstatechange(this.signalingState);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.pendingLocalDescription = null;
|
||||
this.pendingRemoteDescription = null;
|
||||
this.signalingState = "stable";
|
||||
this.onsignalingstatechange(this.signalingState);
|
||||
}
|
||||
|
||||
if (this.videoTracks.size != 0 || this.audioTracks.size != 0) {
|
||||
this._mockGatheringIceCandidate(this.videoTracks.size + this.audioTracks.size);
|
||||
}
|
||||
|
||||
//fire ontrack with new tracks, after using tracks clear.
|
||||
if (this.ontrack) {
|
||||
for (const track of this.videoTracks.values()) {
|
||||
this.ontrack({ track: track });
|
||||
}
|
||||
this.videoTracks.clear();
|
||||
|
||||
for (const track of this.audioTracks.values()) {
|
||||
this.ontrack({ track: track });
|
||||
}
|
||||
this.audioTracks.clear();
|
||||
}
|
||||
|
||||
if (this.ondatachannel) {
|
||||
for (const channel of this.channels.values()) {
|
||||
this.ondatachannel({ channel: channel });
|
||||
}
|
||||
this.channels.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async _mockGatheringIceCandidate(count) {
|
||||
this.iceGatheringState = "gathering";
|
||||
if (this.onicegatheringstatechange) {
|
||||
this.onicegatheringstatechange(this.iceGatheringState);
|
||||
}
|
||||
for (let index = 0; index < count; index++) {
|
||||
await this.delay();
|
||||
const newCandidate = { candidate: getUniqueId(), sdpMLineIndex: index, sdpMid: index };
|
||||
if (this.onicecandidate) {
|
||||
this.onicecandidate(newCandidate);
|
||||
}
|
||||
}
|
||||
this.iceGatheringState = "complete";
|
||||
if (this.onicegatheringstatechange) {
|
||||
this.onicegatheringstatechange(this.iceGatheringState);
|
||||
}
|
||||
if (this.onicecandidate) {
|
||||
this.onicecandidate({ candidate: null, sdpMLineIndex: null, sdpMid: null });
|
||||
}
|
||||
}
|
||||
|
||||
async addIceCandidate(candidate) {
|
||||
await this.delay();
|
||||
if (this.remoteDescription == null) {
|
||||
throw "InvalidStateError";
|
||||
}
|
||||
this.candidates.push(candidate);
|
||||
}
|
||||
|
||||
_createTrackAndTransceiver(kind) {
|
||||
const track = { id: getUniqueId(), kind: kind };
|
||||
if (kind == "video") {
|
||||
this.videoTracks.set(track.id, track);
|
||||
} else {
|
||||
this.audioTracks.set(track.id, track);
|
||||
}
|
||||
const transceiver = { direction: "sendrecv", sender: { track: track }, receiver: null, setCodecPreferences: (codecs) => { console.log(codecs); } };
|
||||
this.transceivers.set(this.transceiverCount++, transceiver);
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionDescriptionMock {
|
||||
|
||||
constructor(object) {
|
||||
this.sdp = object.sdp;
|
||||
this.type = object.type;
|
||||
}
|
||||
|
||||
sdp;
|
||||
type;
|
||||
}
|
||||
|
||||
export class IceCandidateMock {
|
||||
constructor(object) {
|
||||
this.candidate = object.candidate;
|
||||
this.sdpMLineIndex = object.sdpMLineIndex;
|
||||
this.sdpMid = object.sdpMid;
|
||||
}
|
||||
|
||||
candidate;
|
||||
sdpMLineIndex;
|
||||
sdpMid;
|
||||
}
|
||||
45
WebApp/client/test/pointercorrect.test.js
Normal file
45
WebApp/client/test/pointercorrect.test.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
LetterBoxType,
|
||||
PointerCorrector
|
||||
} from "../src/pointercorrect.js";
|
||||
|
||||
import {DOMRect} from "./domrect.js";
|
||||
import {DOMHTMLVideoElement} from "./domvideoelement.js";
|
||||
|
||||
describe(`PointerCorrector.map`, () => {
|
||||
test('letterboxType', () => {
|
||||
const rect = new DOMRect(10, 10, 200, 200);
|
||||
const element = new DOMHTMLVideoElement(rect);
|
||||
let corrector = new PointerCorrector(50, 100, element);
|
||||
expect(corrector.letterBoxType).toBe(LetterBoxType.Vertical);
|
||||
corrector.reset(100, 50, element);
|
||||
expect(corrector.letterBoxType).toBe(LetterBoxType.Horizontal);
|
||||
});
|
||||
test('letterboxSize', () => {
|
||||
const rect = new DOMRect(0, 0, 100, 100);
|
||||
const element = new DOMHTMLVideoElement(rect);
|
||||
let corrector = new PointerCorrector(50, 100, element);
|
||||
expect(corrector.letterBoxSize).toBe(25);
|
||||
});
|
||||
test('contentRect', () => {
|
||||
const rect = new DOMRect(0, 0, 100, 100);
|
||||
const element = new DOMHTMLVideoElement(rect);
|
||||
let corrector = new PointerCorrector(50, 100, element);
|
||||
expect(corrector.contentRect.x).toBe(25);
|
||||
expect(corrector.contentRect.y).toBe(0);
|
||||
expect(corrector.contentRect.width).toBe(50);
|
||||
expect(corrector.contentRect.height).toBe(100);
|
||||
});
|
||||
test('mapping', () => {
|
||||
const rect = new DOMRect(10, 10, 200, 200);
|
||||
const element = new DOMHTMLVideoElement(rect);
|
||||
const videoWidth = 100;
|
||||
const videoHeight = 100;
|
||||
let corrector = new PointerCorrector(videoWidth, videoHeight, element);
|
||||
const position = [10, 10];
|
||||
const newPosition = corrector.map(position);
|
||||
expect(newPosition[0]).toBe(0);
|
||||
expect(newPosition[1]).toBe(100);
|
||||
});
|
||||
|
||||
});
|
||||
187
WebApp/client/test/renderstreaming.test.js
Normal file
187
WebApp/client/test/renderstreaming.test.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { MockSignaling, reset } from "./mocksignaling.js";
|
||||
import { waitFor, getUniqueId, getRTCConfiguration } from "./testutils.js";
|
||||
import { RenderStreaming } from "../src/renderstreaming.js";
|
||||
|
||||
describe.each([
|
||||
{ mode: "private" },
|
||||
{ mode: "public" }
|
||||
])('renderstreaming test', ({ mode }) => {
|
||||
const connectionId1 = "12345";
|
||||
|
||||
test(`createConnection in ${mode} mode`, async () => {
|
||||
reset(mode == "private");
|
||||
const config = getRTCConfiguration();
|
||||
const renderstreaming = new RenderStreaming(new MockSignaling(), config);
|
||||
await renderstreaming.start();
|
||||
|
||||
let isConnect = false;
|
||||
renderstreaming.onConnect = () => isConnect = true;
|
||||
await renderstreaming.createConnection(connectionId1);
|
||||
await waitFor(() => isConnect);
|
||||
expect(isConnect).toBe(true);
|
||||
|
||||
await renderstreaming.stop();
|
||||
});
|
||||
|
||||
test(`addTrack in ${mode} mode`, async () => {
|
||||
reset(mode == "private");
|
||||
const config = getRTCConfiguration();
|
||||
const renderstreaming = new RenderStreaming(new MockSignaling(), config);
|
||||
await renderstreaming.start();
|
||||
|
||||
let isConnect = false;
|
||||
renderstreaming.onConnect = () => isConnect = true;
|
||||
await renderstreaming.createConnection(connectionId1);
|
||||
await waitFor(() => isConnect);
|
||||
expect(isConnect).toBe(true);
|
||||
expect(renderstreaming.getTransceivers(connectionId1).length).toBe(0);
|
||||
|
||||
const track = { id: getUniqueId(), kind: "audio" };
|
||||
renderstreaming.addTrack(track);
|
||||
expect(renderstreaming.getTransceivers(connectionId1).length).toBe(1);
|
||||
|
||||
let isDisconnect = false;
|
||||
renderstreaming.onDisconnect = () => isDisconnect = true;
|
||||
await renderstreaming.deleteConnection();
|
||||
await waitFor(() => isDisconnect);
|
||||
expect(isDisconnect).toBe(true);
|
||||
|
||||
await renderstreaming.stop();
|
||||
});
|
||||
|
||||
test(`createChannel in ${mode} mode`, async () => {
|
||||
reset(mode == "private");
|
||||
const config = getRTCConfiguration();
|
||||
const renderstreaming = new RenderStreaming(new MockSignaling(), config);
|
||||
await renderstreaming.start();
|
||||
|
||||
let isConnect = false;
|
||||
renderstreaming.onConnect = () => isConnect = true;
|
||||
await renderstreaming.createConnection(connectionId1);
|
||||
await waitFor(() => isConnect);
|
||||
expect(isConnect).toBe(true);
|
||||
expect(renderstreaming.getTransceivers(connectionId1).length).toBe(0);
|
||||
|
||||
const label = "testlabel";
|
||||
const channel = renderstreaming.createDataChannel(label);
|
||||
expect(channel.label).toBe(label);
|
||||
|
||||
let isDisconnect = false;
|
||||
renderstreaming.onDisconnect = () => isDisconnect = true;
|
||||
await renderstreaming.deleteConnection();
|
||||
await waitFor(() => isDisconnect);
|
||||
expect(isDisconnect).toBe(true);
|
||||
|
||||
await renderstreaming.stop();
|
||||
});
|
||||
|
||||
test(`onTrackEvent in ${mode} mode`, async () => {
|
||||
reset(mode == "private");
|
||||
|
||||
const config = getRTCConfiguration();
|
||||
const renderstreaming1 = new RenderStreaming(new MockSignaling(), config);
|
||||
const renderstreaming2 = new RenderStreaming(new MockSignaling(), config);
|
||||
await renderstreaming1.start();
|
||||
await renderstreaming2.start();
|
||||
|
||||
let isConnect1 = false;
|
||||
renderstreaming1.onConnect = () => isConnect1 = true;
|
||||
let isConnect2 = false;
|
||||
renderstreaming2.onConnect = () => isConnect2 = true;
|
||||
|
||||
await renderstreaming1.createConnection(connectionId1);
|
||||
await renderstreaming2.createConnection(connectionId1);
|
||||
await waitFor(() => isConnect1 && isConnect2);
|
||||
expect(isConnect1).toBe(true);
|
||||
expect(isConnect2).toBe(true);
|
||||
|
||||
let isGotOffer1 = false;
|
||||
let isOnTrack1 = false;
|
||||
let isGotAnswer2 = false;
|
||||
renderstreaming1.onGotOffer = () => { isGotOffer1 = true; };
|
||||
renderstreaming1.onTrackEvent = () => { isOnTrack1 = true; };
|
||||
renderstreaming2.onGotAnswer = () => { isGotAnswer2 = true; };
|
||||
|
||||
expect(renderstreaming1.getTransceivers(connectionId1).length).toBe(0);
|
||||
|
||||
const track = { id: getUniqueId(), kind: "audio" };
|
||||
renderstreaming2.addTrack(track);
|
||||
expect(renderstreaming2.getTransceivers(connectionId1).length).toBe(1);
|
||||
await waitFor(() => isGotOffer1);
|
||||
expect(isGotOffer1).toBe(true);
|
||||
|
||||
await waitFor(() => isOnTrack1);
|
||||
expect(isOnTrack1).toBe(true);
|
||||
expect(renderstreaming1.getTransceivers(connectionId1).length).toBe(1);
|
||||
|
||||
await waitFor(() => isGotAnswer2);
|
||||
expect(isGotAnswer2).toBe(true);
|
||||
|
||||
let isDisconnect1 = false;
|
||||
renderstreaming1.onDisconnect = () => isDisconnect1 = true;
|
||||
let isDisconnect2 = false;
|
||||
renderstreaming2.onDisconnect = () => isDisconnect2 = true;
|
||||
|
||||
await renderstreaming1.deleteConnection();
|
||||
await renderstreaming2.deleteConnection();
|
||||
await waitFor(() => isDisconnect1 && isDisconnect2);
|
||||
expect(isDisconnect1).toBe(true);
|
||||
expect(isDisconnect2).toBe(true);
|
||||
|
||||
await renderstreaming1.stop();
|
||||
await renderstreaming2.stop();
|
||||
});
|
||||
|
||||
test(`onAddDataChannel in ${mode} mode`, async () => {
|
||||
reset(mode == "private");
|
||||
|
||||
const config = getRTCConfiguration();
|
||||
const renderstreaming1 = new RenderStreaming(new MockSignaling(), config);
|
||||
const renderstreaming2 = new RenderStreaming(new MockSignaling(), config);
|
||||
await renderstreaming1.start();
|
||||
await renderstreaming2.start();
|
||||
|
||||
let isConnect1 = false;
|
||||
renderstreaming1.onConnect = () => isConnect1 = true;
|
||||
let isConnect2 = false;
|
||||
renderstreaming2.onConnect = () => isConnect2 = true;
|
||||
|
||||
await renderstreaming1.createConnection(connectionId1);
|
||||
await renderstreaming2.createConnection(connectionId1);
|
||||
await waitFor(() => isConnect1 && isConnect2);
|
||||
expect(isConnect1).toBe(true);
|
||||
expect(isConnect2).toBe(true);
|
||||
|
||||
let isGotOffer1 = false;
|
||||
let isAddChannel1 = false;
|
||||
let isGotAnswer2 = false;
|
||||
renderstreaming1.onGotOffer = () => { isGotOffer1 = true; };
|
||||
renderstreaming1.onAddChannel = () => { isAddChannel1 = true; };
|
||||
renderstreaming2.onGotAnswer = () => { isGotAnswer2 = true; };
|
||||
|
||||
renderstreaming2.createDataChannel("testchannel");
|
||||
await waitFor(() => isGotOffer1);
|
||||
expect(isGotOffer1).toBe(true);
|
||||
|
||||
await waitFor(() => isAddChannel1);
|
||||
expect(isAddChannel1).toBe(true);
|
||||
|
||||
await waitFor(() => isGotAnswer2);
|
||||
expect(isGotAnswer2).toBe(true);
|
||||
|
||||
let isDisconnect1 = false;
|
||||
renderstreaming1.onDisconnect = () => isDisconnect1 = true;
|
||||
let isDisconnect2 = false;
|
||||
renderstreaming2.onDisconnect = () => isDisconnect2 = true;
|
||||
|
||||
await renderstreaming1.deleteConnection();
|
||||
await renderstreaming2.deleteConnection();
|
||||
await waitFor(() => isDisconnect1 && isDisconnect2);
|
||||
expect(isDisconnect1).toBe(true);
|
||||
expect(isDisconnect2).toBe(true);
|
||||
|
||||
await renderstreaming1.stop();
|
||||
await renderstreaming2.stop();
|
||||
});
|
||||
|
||||
});
|
||||
18
WebApp/client/test/resizeobservermock.js
Normal file
18
WebApp/client/test/resizeobservermock.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// mock class
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
let instanceResize = null;
|
||||
/* eslint-disable no-unused-vars */
|
||||
let callbackResize = null;
|
||||
|
||||
export default class ResizeObserverMock {
|
||||
constructor(callback) {
|
||||
instanceResize = this;
|
||||
callbackResize = callback;
|
||||
}
|
||||
disconnect() { }
|
||||
/* eslint-disable no-unused-vars */
|
||||
observe(target, options) { }
|
||||
/* eslint-disable no-unused-vars */
|
||||
unobserve(target) { }
|
||||
}
|
||||
143
WebApp/client/test/sender.test.js
Normal file
143
WebApp/client/test/sender.test.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
InputRemoting,
|
||||
} from "../src/inputremoting.js";
|
||||
|
||||
import {
|
||||
Sender,
|
||||
Observer
|
||||
} from "../src/sender.js";
|
||||
|
||||
import {jest} from '@jest/globals';
|
||||
import {DOMRect} from "./domrect.js";
|
||||
|
||||
// mock
|
||||
|
||||
class RTCDataChannel {
|
||||
get readyState() {
|
||||
return "open";
|
||||
}
|
||||
/* eslint-disable no-unused-vars */
|
||||
send(message) {
|
||||
}
|
||||
}
|
||||
|
||||
describe(`Sender`, () => {
|
||||
let inputRemoting = null;
|
||||
let sender = null;
|
||||
let observer = null;
|
||||
let events = {};
|
||||
let dc = null;
|
||||
beforeEach(async () => {
|
||||
// Empty our events before each test case
|
||||
events = {};
|
||||
|
||||
// Define the addEventListener method with a Jest mock function
|
||||
document.addEventListener = jest.fn((event, callback) => {
|
||||
events[event] = callback;
|
||||
});
|
||||
|
||||
document.removeEventListener = jest.fn((event, callback) => {
|
||||
delete events[event];
|
||||
});
|
||||
document.getBoundingClientRect = function(){ return new DOMRect(0,0,0,0); };
|
||||
sender = new Sender(document);
|
||||
inputRemoting = new InputRemoting(sender);
|
||||
dc = new RTCDataChannel();
|
||||
observer = new Observer(dc);
|
||||
});
|
||||
test('devices', () => {
|
||||
sender.addMouse();
|
||||
expect(sender.devices.length).toBe(1);
|
||||
sender.addKeyboard();
|
||||
expect(sender.devices.length).toBe(2);
|
||||
});
|
||||
test('send messages while called startSending', () => {
|
||||
jest.spyOn(dc, 'send');
|
||||
sender.addMouse();
|
||||
sender.addKeyboard();
|
||||
inputRemoting.subscribe(observer);
|
||||
inputRemoting.startSending();
|
||||
expect(dc.send).toHaveBeenCalled();
|
||||
});
|
||||
describe('mouse', () => {
|
||||
test('click', () => {
|
||||
jest.spyOn(dc, 'send');
|
||||
sender.addMouse();
|
||||
inputRemoting.subscribe(observer);
|
||||
inputRemoting.startSending();
|
||||
events.click(
|
||||
new MouseEvent('click', { buttons:1, clientX:0, clientY:0} ));
|
||||
expect(dc.send).toHaveBeenCalledWith(expect.any(ArrayBuffer));
|
||||
});
|
||||
test('mousemove', () => {
|
||||
jest.spyOn(dc, 'send');
|
||||
sender.addMouse();
|
||||
inputRemoting.subscribe(observer);
|
||||
inputRemoting.startSending();
|
||||
events.mousemove(
|
||||
new MouseEvent('mousemove', { buttons:1, deltaX:0, deltaY:0 }));
|
||||
expect(dc.send).toHaveBeenCalledWith(expect.any(ArrayBuffer));
|
||||
});
|
||||
test('wheel', () => {
|
||||
jest.spyOn(dc, 'send');
|
||||
sender.addMouse();
|
||||
inputRemoting.subscribe(observer);
|
||||
inputRemoting.startSending();
|
||||
events.wheel(
|
||||
new WheelEvent('wheel', { wheelDelta:0, deltaX:0, deltaY:0 }));
|
||||
expect(dc.send).toHaveBeenCalledWith(expect.any(ArrayBuffer));
|
||||
});
|
||||
});
|
||||
describe('keyboard', () => {
|
||||
test('keydown', () => {
|
||||
jest.spyOn(dc, 'send');
|
||||
sender.addKeyboard();
|
||||
inputRemoting.subscribe(observer);
|
||||
inputRemoting.startSending();
|
||||
events.keydown(
|
||||
new KeyboardEvent('keydown', { code: 'KeyA' }));
|
||||
expect(dc.send).toHaveBeenCalledWith(expect.any(ArrayBuffer));
|
||||
});
|
||||
test('keydown repeat', () => {
|
||||
jest.spyOn(dc, 'send');
|
||||
sender.addKeyboard();
|
||||
inputRemoting.subscribe(observer);
|
||||
inputRemoting.startSending();
|
||||
events.keydown(
|
||||
new KeyboardEvent('keydown', { code: 'KeyA', repeat: true }));
|
||||
expect(dc.send).toHaveBeenCalledWith(expect.any(ArrayBuffer));
|
||||
});
|
||||
});
|
||||
describe('touchscreen', () => {
|
||||
test('touchstart', () => {
|
||||
jest.spyOn(dc, 'send');
|
||||
sender.addTouchscreen();
|
||||
inputRemoting.subscribe(observer);
|
||||
inputRemoting.startSending();
|
||||
events.touchstart(
|
||||
new TouchEvent("touchstart", {
|
||||
changedTouches: [{ // InputInit
|
||||
identifier: 0,
|
||||
target: null,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
radiusX: 0,
|
||||
radiusY: 0,
|
||||
rotationAngle: 0,
|
||||
force: 0,
|
||||
altitudeAngle: 0,
|
||||
azimuthAngle:0,
|
||||
touchType: "direct"
|
||||
}]
|
||||
}));
|
||||
expect(dc.send).toHaveBeenCalledWith(expect.any(ArrayBuffer));
|
||||
});
|
||||
});
|
||||
describe('gamepad', () => {
|
||||
//todo
|
||||
});
|
||||
});
|
||||
484
WebApp/client/test/signaling.test.js
Normal file
484
WebApp/client/test/signaling.test.js
Normal file
@@ -0,0 +1,484 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import * as Path from 'path';
|
||||
import { setup, teardown } from 'jest-dev-server';
|
||||
import { Signaling, WebSocketSignaling } from "../src/signaling.js";
|
||||
import { MockSignaling, reset } from "./mocksignaling.js";
|
||||
import { waitFor, sleep, serverExeName } from "./testutils.js";
|
||||
|
||||
const portNumber = 8081;
|
||||
jest.setTimeout(10000);
|
||||
|
||||
describe.each([
|
||||
{ mode: "mock" },
|
||||
{ mode: "http" },
|
||||
{ mode: "websocket" },
|
||||
])('signaling test in public mode', ({ mode }) => {
|
||||
let signaling1;
|
||||
let signaling2;
|
||||
const connectionId1 = "12345";
|
||||
const connectionId2 = "67890";
|
||||
const testsdp = "test sdp";
|
||||
const testcandidate = "test candidate";
|
||||
|
||||
beforeAll(async () => {
|
||||
if (mode == "mock") {
|
||||
reset(false);
|
||||
signaling1 = new MockSignaling(1);
|
||||
signaling2 = new MockSignaling(1);
|
||||
} else {
|
||||
const path = Path.resolve(`../bin~/${serverExeName()}`);
|
||||
let cmd = `${path} -p ${portNumber}`;
|
||||
if (mode == "http") {
|
||||
cmd += " -t http";
|
||||
}
|
||||
|
||||
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
|
||||
|
||||
if (mode == "http") {
|
||||
signaling1 = new Signaling(1);
|
||||
signaling2 = new Signaling(1);
|
||||
}
|
||||
|
||||
if (mode == "websocket") {
|
||||
signaling1 = new WebSocketSignaling(1);
|
||||
signaling2 = new WebSocketSignaling(1);
|
||||
}
|
||||
}
|
||||
|
||||
await signaling1.start();
|
||||
await signaling2.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await signaling1.stop();
|
||||
await signaling2.stop();
|
||||
signaling1 = null;
|
||||
signaling2 = null;
|
||||
|
||||
if (mode == "mock") {
|
||||
return;
|
||||
}
|
||||
|
||||
await teardown();
|
||||
// work around for linux, waitng kill server process
|
||||
await sleep(1000);
|
||||
});
|
||||
|
||||
test(`onConnect using ${mode}`, async () => {
|
||||
const signaling1Spy = jest.spyOn(signaling1, 'dispatchEvent');
|
||||
let connectRes;
|
||||
let disconnectRes;
|
||||
signaling1.addEventListener('connect', (e) => connectRes = e.detail);
|
||||
signaling1.addEventListener('disconnect', (e) => disconnectRes = e.detail);
|
||||
|
||||
await signaling1.createConnection(connectionId1);
|
||||
await waitFor(() => connectRes != null);
|
||||
expect(connectRes.connectionId).toBe(connectionId1);
|
||||
expect(connectRes.polite).toBe(true);
|
||||
|
||||
await signaling1.deleteConnection(connectionId1);
|
||||
await waitFor(() => disconnectRes != null);
|
||||
expect(disconnectRes.connectionId).toBe(connectionId1);
|
||||
|
||||
const disconnectCalledCount = signaling1Spy.mock.calls.map(x => x[0].type).filter(x => x == "disconnect").length;
|
||||
expect(disconnectCalledCount).toBe(1);
|
||||
|
||||
signaling1Spy.mockRestore();
|
||||
});
|
||||
|
||||
test(`onOffer using ${mode}`, async () => {
|
||||
let connectRes1;
|
||||
let disconnectRes1;
|
||||
signaling1.addEventListener('connect', (e) => connectRes1 = e.detail);
|
||||
signaling1.addEventListener('disconnect', (e) => disconnectRes1 = e.detail);
|
||||
|
||||
let connectRes2;
|
||||
let disconnectRes2;
|
||||
let offerRes2;
|
||||
signaling2.addEventListener('connect', (e) => connectRes2 = e.detail);
|
||||
signaling2.addEventListener('disconnect', (e) => disconnectRes2 = e.detail);
|
||||
signaling2.addEventListener('offer', (e) => offerRes2 = e.detail);
|
||||
|
||||
await signaling1.createConnection(connectionId1);
|
||||
await signaling2.createConnection(connectionId2);
|
||||
await waitFor(() => connectRes1 != null && connectRes2 != null);
|
||||
expect(connectRes1.connectionId).toBe(connectionId1);
|
||||
expect(connectRes2.connectionId).toBe(connectionId2);
|
||||
|
||||
await signaling1.sendOffer(connectionId1, testsdp);
|
||||
await waitFor(() => offerRes2 != null);
|
||||
expect(offerRes2.connectionId).toBe(connectionId1);
|
||||
expect(offerRes2.polite).toBe(false);
|
||||
|
||||
await signaling1.deleteConnection(connectionId1);
|
||||
await waitFor(() => disconnectRes1 != null);
|
||||
expect(disconnectRes1.connectionId).toBe(connectionId1);
|
||||
await signaling2.deleteConnection(connectionId2);
|
||||
await waitFor(() => disconnectRes2 != null);
|
||||
expect(disconnectRes2.connectionId).toBe(connectionId2);
|
||||
});
|
||||
|
||||
test(`onAnswer using ${mode}`, async () => {
|
||||
let connectRes1;
|
||||
let disconnectRes1;
|
||||
let answerRes1;
|
||||
signaling1.addEventListener('connect', (e) => connectRes1 = e.detail);
|
||||
signaling1.addEventListener('disconnect', (e) => disconnectRes1 = e.detail);
|
||||
signaling1.addEventListener('answer', (e) => answerRes1 = e.detail);
|
||||
|
||||
let connectRes2;
|
||||
let disconnectRes2;
|
||||
let offerRes2;
|
||||
signaling2.addEventListener('connect', (e) => connectRes2 = e.detail);
|
||||
signaling2.addEventListener('disconnect', (e) => disconnectRes2 = e.detail);
|
||||
signaling2.addEventListener('offer', (e) => offerRes2 = e.detail);
|
||||
|
||||
await signaling1.createConnection(connectionId1);
|
||||
await signaling2.createConnection(connectionId2);
|
||||
await waitFor(() => connectRes1 != null && connectRes2 != null);
|
||||
|
||||
await signaling1.sendOffer(connectionId1, testsdp);
|
||||
await waitFor(() => offerRes2 != null);
|
||||
expect(offerRes2.connectionId).toBe(connectionId1);
|
||||
expect(offerRes2.sdp).toBe(testsdp);
|
||||
|
||||
signaling2.sendAnswer(connectionId1, testsdp);
|
||||
await waitFor(() => answerRes1 != null);
|
||||
expect(answerRes1.connectionId).toBe(connectionId1);
|
||||
expect(answerRes1.sdp).toBe(testsdp);
|
||||
|
||||
await signaling1.deleteConnection(connectionId1);
|
||||
await waitFor(() => disconnectRes1 != null);
|
||||
await signaling2.deleteConnection(connectionId2);
|
||||
await waitFor(() => disconnectRes2 != null);
|
||||
});
|
||||
|
||||
test(`onCandidate using ${mode}`, async () => {
|
||||
let connectRes1;
|
||||
let disconnectRes1;
|
||||
let answerRes1;
|
||||
let candidateRes1;
|
||||
signaling1.addEventListener('connect', (e) => connectRes1 = e.detail);
|
||||
signaling1.addEventListener('disconnect', (e) => disconnectRes1 = e.detail);
|
||||
signaling1.addEventListener('answer', (e) => answerRes1 = e.detail);
|
||||
signaling1.addEventListener('candidate', (e) => candidateRes1 = e.detail);
|
||||
|
||||
let connectRes2;
|
||||
let disconnectRes2;
|
||||
let offerRes2;
|
||||
let candidateRes2;
|
||||
signaling2.addEventListener('connect', (e) => connectRes2 = e.detail);
|
||||
signaling2.addEventListener('disconnect', (e) => disconnectRes2 = e.detail);
|
||||
signaling2.addEventListener('offer', (e) => offerRes2 = e.detail);
|
||||
signaling2.addEventListener('candidate', (e) => candidateRes2 = e.detail);
|
||||
|
||||
await signaling1.createConnection(connectionId1);
|
||||
await signaling2.createConnection(connectionId2);
|
||||
await waitFor(() => connectRes1 != null && connectRes2 != null);
|
||||
|
||||
await signaling1.sendOffer(connectionId1, testsdp);
|
||||
await waitFor(() => offerRes2 != null);
|
||||
expect(offerRes2.connectionId).toBe(connectionId1);
|
||||
expect(offerRes2.sdp).toBe(testsdp);
|
||||
|
||||
signaling2.sendAnswer(connectionId1, testsdp);
|
||||
await waitFor(() => answerRes1 != null);
|
||||
expect(answerRes1.connectionId).toBe(connectionId1);
|
||||
expect(answerRes1.sdp).toBe(testsdp);
|
||||
|
||||
await signaling2.sendCandidate(connectionId1, testcandidate, 1, 1);
|
||||
await waitFor(() => candidateRes1 != null);
|
||||
expect(candidateRes1.connectionId).toBe(connectionId1);
|
||||
expect(candidateRes1.candidate).toBe(testcandidate);
|
||||
expect(candidateRes1.sdpMid).toBe(1);
|
||||
expect(candidateRes1.sdpMLineIndex).toBe(1);
|
||||
|
||||
await signaling1.sendCandidate(connectionId1, testcandidate, 1, 1);
|
||||
await waitFor(() => candidateRes2 != null);
|
||||
expect(candidateRes2.connectionId).toBe(connectionId1);
|
||||
expect(candidateRes2.candidate).toBe(testcandidate);
|
||||
expect(candidateRes2.sdpMid).toBe(1);
|
||||
expect(candidateRes2.sdpMLineIndex).toBe(1);
|
||||
|
||||
await signaling1.deleteConnection(connectionId1);
|
||||
await waitFor(() => disconnectRes1 != null);
|
||||
await signaling2.deleteConnection(connectionId2);
|
||||
await waitFor(() => disconnectRes2 != null);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ mode: "mock" },
|
||||
{ mode: "http" },
|
||||
{ mode: "websocket" },
|
||||
])('signaling test in private mode', ({ mode }) => {
|
||||
let signaling1;
|
||||
let signaling2;
|
||||
const connectionId = "12345";
|
||||
const testsdp = "test sdp";
|
||||
const testcandidate = "test candidate";
|
||||
|
||||
beforeAll(async () => {
|
||||
if (mode == "mock") {
|
||||
reset(true);
|
||||
signaling1 = new MockSignaling(1);
|
||||
signaling2 = new MockSignaling(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const path = Path.resolve(`../bin~/${serverExeName()}`);
|
||||
let cmd = `${path} -p ${portNumber} -m private`;
|
||||
if (mode == "http") {
|
||||
cmd += " -t http";
|
||||
}
|
||||
|
||||
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
|
||||
|
||||
if (mode == "http") {
|
||||
signaling1 = new Signaling(1);
|
||||
signaling2 = new Signaling(1);
|
||||
}
|
||||
|
||||
if (mode == "websocket") {
|
||||
signaling1 = new WebSocketSignaling(1);
|
||||
signaling2 = new WebSocketSignaling(1);
|
||||
}
|
||||
|
||||
await signaling1.start();
|
||||
await signaling2.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await signaling1.stop();
|
||||
await signaling2.stop();
|
||||
signaling1 = null;
|
||||
signaling2 = null;
|
||||
|
||||
if (mode == "mock") {
|
||||
return;
|
||||
}
|
||||
|
||||
await teardown();
|
||||
// work around for linux, waitng kill server process
|
||||
await sleep(1000);
|
||||
});
|
||||
|
||||
test(`onConnect using ${mode}`, async () => {
|
||||
let connectRes1;
|
||||
let disconnectRes1;
|
||||
signaling1.addEventListener('connect', (e) => connectRes1 = e.detail);
|
||||
signaling1.addEventListener('disconnect', (e) => disconnectRes1 = e.detail);
|
||||
|
||||
let connectRes2;
|
||||
let disconnectRes2;
|
||||
signaling2.addEventListener('connect', (e) => connectRes2 = e.detail);
|
||||
signaling2.addEventListener('disconnect', (e) => disconnectRes2 = e.detail);
|
||||
|
||||
await signaling1.createConnection(connectionId);
|
||||
await waitFor(() => connectRes1 != null);
|
||||
expect(connectRes1.connectionId).toBe(connectionId);
|
||||
expect(connectRes1.polite).toBe(false);
|
||||
|
||||
await signaling2.createConnection(connectionId);
|
||||
await waitFor(() => connectRes2 != null);
|
||||
expect(connectRes2.connectionId).toBe(connectionId);
|
||||
expect(connectRes2.polite).toBe(true);
|
||||
|
||||
await sleep(signaling1.interval * 2);
|
||||
|
||||
await signaling1.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes1 != null && disconnectRes2 != null);
|
||||
expect(disconnectRes1.connectionId).toBe(connectionId);
|
||||
expect(disconnectRes2.connectionId).toBe(connectionId);
|
||||
|
||||
disconnectRes2 = null;
|
||||
await signaling2.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes2 != null);
|
||||
});
|
||||
|
||||
test(`onOffer using ${mode}`, async () => {
|
||||
let connectRes1;
|
||||
let disconnectRes1;
|
||||
signaling1.addEventListener('connect', (e) => connectRes1 = e.detail);
|
||||
signaling1.addEventListener('disconnect', (e) => disconnectRes1 = e.detail);
|
||||
|
||||
let connectRes2;
|
||||
let disconnectRes2;
|
||||
let offerRes2;
|
||||
signaling2.addEventListener('connect', (e) => connectRes2 = e.detail);
|
||||
signaling2.addEventListener('disconnect', (e) => disconnectRes2 = e.detail);
|
||||
signaling2.addEventListener('offer', (e) => offerRes2 = e.detail);
|
||||
|
||||
await signaling1.createConnection(connectionId);
|
||||
await waitFor(() => connectRes1 != null);
|
||||
expect(connectRes1.connectionId).toBe(connectionId);
|
||||
|
||||
signaling1.sendOffer(connectionId, testsdp);
|
||||
await sleep(signaling1.interval * 2);
|
||||
// Do not receive offer other signaling if not connected same sendoffer connectionId in private mode
|
||||
expect(offerRes2).toBeUndefined();
|
||||
|
||||
await signaling2.createConnection(connectionId);
|
||||
await waitFor(() => connectRes2 != null);
|
||||
expect(connectRes2.connectionId).toBe(connectionId);
|
||||
|
||||
await signaling1.sendOffer(connectionId, testsdp);
|
||||
await waitFor(() => offerRes2 != null);
|
||||
expect(offerRes2.connectionId).toBe(connectionId);
|
||||
expect(offerRes2.polite).toBe(true);
|
||||
|
||||
await signaling1.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes1 != null && disconnectRes2 != null);
|
||||
expect(disconnectRes1.connectionId).toBe(connectionId);
|
||||
expect(disconnectRes2.connectionId).toBe(connectionId);
|
||||
|
||||
disconnectRes2 = null;
|
||||
await signaling2.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes2 != null);
|
||||
});
|
||||
|
||||
test(`onAnswer using ${mode}`, async () => {
|
||||
let connectRes1;
|
||||
let disconnectRes1;
|
||||
let answerRes1;
|
||||
signaling1.addEventListener('connect', (e) => connectRes1 = e.detail);
|
||||
signaling1.addEventListener('disconnect', (e) => disconnectRes1 = e.detail);
|
||||
signaling1.addEventListener('answer', (e) => answerRes1 = e.detail);
|
||||
|
||||
let connectRes2;
|
||||
let disconnectRes2;
|
||||
let offerRes2;
|
||||
signaling2.addEventListener('connect', (e) => connectRes2 = e.detail);
|
||||
signaling2.addEventListener('disconnect', (e) => disconnectRes2 = e.detail);
|
||||
signaling2.addEventListener('offer', (e) => offerRes2 = e.detail);
|
||||
|
||||
await signaling1.createConnection(connectionId);
|
||||
await signaling2.createConnection(connectionId);
|
||||
await waitFor(() => connectRes1 != null && connectRes2 != null);
|
||||
|
||||
await signaling1.sendOffer(connectionId, testsdp);
|
||||
await waitFor(() => offerRes2 != null);
|
||||
expect(offerRes2.connectionId).toBe(connectionId);
|
||||
expect(offerRes2.sdp).toBe(testsdp);
|
||||
|
||||
await signaling2.sendAnswer(connectionId, testsdp);
|
||||
await waitFor(() => answerRes1 != null);
|
||||
expect(answerRes1.connectionId).toBe(connectionId);
|
||||
expect(answerRes1.sdp).toBe(testsdp);
|
||||
|
||||
await signaling1.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes1 != null && disconnectRes2 != null);
|
||||
expect(disconnectRes1.connectionId).toBe(connectionId);
|
||||
expect(disconnectRes2.connectionId).toBe(connectionId);
|
||||
|
||||
disconnectRes2 = null;
|
||||
await signaling2.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes2 != null);
|
||||
});
|
||||
|
||||
test(`onCandidate using ${mode}`, async () => {
|
||||
let connectRes1;
|
||||
let disconnectRes1;
|
||||
let answerRes1;
|
||||
let candidateRes1;
|
||||
signaling1.addEventListener('connect', (e) => connectRes1 = e.detail);
|
||||
signaling1.addEventListener('disconnect', (e) => disconnectRes1 = e.detail);
|
||||
signaling1.addEventListener('answer', (e) => answerRes1 = e.detail);
|
||||
signaling1.addEventListener('candidate', (e) => candidateRes1 = e.detail);
|
||||
|
||||
let connectRes2;
|
||||
let disconnectRes2;
|
||||
let offerRes2;
|
||||
let candidateRes2;
|
||||
signaling2.addEventListener('connect', (e) => connectRes2 = e.detail);
|
||||
signaling2.addEventListener('disconnect', (e) => disconnectRes2 = e.detail);
|
||||
signaling2.addEventListener('offer', (e) => offerRes2 = e.detail);
|
||||
signaling2.addEventListener('candidate', (e) => candidateRes2 = e.detail);
|
||||
|
||||
await signaling1.createConnection(connectionId);
|
||||
await signaling2.createConnection(connectionId);
|
||||
await waitFor(() => connectRes1 != null && connectRes2 != null);
|
||||
|
||||
await signaling1.sendOffer(connectionId, testsdp);
|
||||
await waitFor(() => offerRes2 != null);
|
||||
expect(offerRes2.connectionId).toBe(connectionId);
|
||||
expect(offerRes2.sdp).toBe(testsdp);
|
||||
|
||||
await signaling2.sendAnswer(connectionId, testsdp);
|
||||
await waitFor(() => answerRes1 != null);
|
||||
expect(answerRes1.connectionId).toBe(connectionId);
|
||||
expect(answerRes1.sdp).toBe(testsdp);
|
||||
|
||||
await signaling2.sendCandidate(connectionId, testcandidate, 1, 1);
|
||||
await waitFor(() => candidateRes1 != null);
|
||||
expect(candidateRes1.connectionId).toBe(connectionId);
|
||||
expect(candidateRes1.candidate).toBe(testcandidate);
|
||||
expect(candidateRes1.sdpMLineIndex).toBe(1);
|
||||
expect(candidateRes1.sdpMid).toBe(1);
|
||||
|
||||
await signaling1.sendCandidate(connectionId, testcandidate, 1, 1);
|
||||
await waitFor(() => candidateRes2 != null);
|
||||
expect(candidateRes2.connectionId).toBe(connectionId);
|
||||
expect(candidateRes2.candidate).toBe(testcandidate);
|
||||
expect(candidateRes2.sdpMLineIndex).toBe(1);
|
||||
expect(candidateRes2.sdpMid).toBe(1);
|
||||
|
||||
await signaling1.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes1 != null && disconnectRes2 != null);
|
||||
expect(disconnectRes1.connectionId).toBe(connectionId);
|
||||
expect(disconnectRes2.connectionId).toBe(connectionId);
|
||||
|
||||
disconnectRes2 = null;
|
||||
await signaling2.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes2 != null);
|
||||
});
|
||||
|
||||
test(`notReceiveOwnOfferAnswer using ${mode}`, async () => {
|
||||
let connectRes1;
|
||||
let disconnectRes1;
|
||||
let offerRes1;
|
||||
let answerRes1;
|
||||
signaling1.addEventListener('connect', (e) => connectRes1 = e.detail);
|
||||
signaling1.addEventListener('disconnect', (e) => disconnectRes1 = e.detail);
|
||||
|
||||
let connectRes2;
|
||||
let disconnectRes2;
|
||||
let offerRes2;
|
||||
let answerRes2;
|
||||
signaling2.addEventListener('connect', (e) => connectRes2 = e.detail);
|
||||
signaling2.addEventListener('disconnect', (e) => disconnectRes2 = e.detail);
|
||||
|
||||
await signaling1.createConnection(connectionId);
|
||||
await signaling2.createConnection(connectionId);
|
||||
await waitFor(() => connectRes1 != null && connectRes2 != null);
|
||||
|
||||
signaling1.addEventListener('offer', (e) => offerRes1 = e.detail);
|
||||
signaling2.addEventListener('offer', (e) => offerRes2 = e.detail);
|
||||
await signaling1.sendOffer(connectionId, testsdp);
|
||||
await waitFor(() => offerRes2 != null);
|
||||
await sleep(signaling1.interval * 2);
|
||||
expect(offerRes1).toBeUndefined();
|
||||
expect(offerRes2).not.toBeUndefined();
|
||||
expect(offerRes2.connectionId).toBe(connectionId);
|
||||
expect(offerRes2.sdp).toBe(testsdp);
|
||||
|
||||
signaling1.addEventListener('answer', (e) => answerRes1 = e.detail);
|
||||
signaling2.addEventListener('answer', (e) => answerRes2 = e.detail);
|
||||
await signaling2.sendAnswer(connectionId, testsdp);
|
||||
await waitFor(() => answerRes1 != null);
|
||||
await sleep(signaling2.interval * 2);
|
||||
expect(answerRes1).not.toBeUndefined();
|
||||
expect(answerRes1.connectionId).toBe(connectionId);
|
||||
expect(answerRes1.sdp).toBe(testsdp);
|
||||
expect(answerRes2).toBeUndefined();
|
||||
|
||||
await signaling1.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes1 != null && disconnectRes2 != null);
|
||||
expect(disconnectRes1.connectionId).toBe(connectionId);
|
||||
expect(disconnectRes2.connectionId).toBe(connectionId);
|
||||
|
||||
disconnectRes2 = null;
|
||||
await signaling2.deleteConnection(connectionId);
|
||||
await waitFor(() => disconnectRes2 != null);
|
||||
});
|
||||
});
|
||||
39
WebApp/client/test/testutils.js
Normal file
39
WebApp/client/test/testutils.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import process from "process";
|
||||
|
||||
export function waitFor(conditionFunction) {
|
||||
|
||||
const poll = resolve => {
|
||||
if (conditionFunction()) resolve();
|
||||
else setTimeout(() => poll(resolve), 100);
|
||||
};
|
||||
|
||||
return new Promise(poll);
|
||||
}
|
||||
|
||||
export async function sleep(milisecond) {
|
||||
return new Promise(resolve => setTimeout(resolve, milisecond));
|
||||
}
|
||||
|
||||
export function serverExeName() {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return 'webserver.exe';
|
||||
case 'darwin':
|
||||
return 'webserver_mac';
|
||||
case 'linux':
|
||||
return 'webserver';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getUniqueId() {
|
||||
return new Date().getTime().toString(16) + Math.floor(1000 * Math.random()).toString(16);
|
||||
}
|
||||
|
||||
export function getRTCConfiguration() {
|
||||
let config = {};
|
||||
config.sdpSemantics = 'unified-plan';
|
||||
config.iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }];
|
||||
return config;
|
||||
}
|
||||
Reference in New Issue
Block a user