This commit is contained in:
zhangzheng
2026-02-27 18:35:40 +08:00
parent adef8b4cce
commit 1bb1fee5cc
265 changed files with 104076 additions and 92 deletions

View 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"
}
}

View 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,
};

View 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

File diff suppressed because it is too large Load Diff

View 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"
}

View 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;
}

View 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>

View 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 = '';
}

View 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);
}
}

View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>

View 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;
}

View 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);
}
}

View 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();

View 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;
}

View 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();
}
}

View 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;
}

View 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>

View 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 = '';
}

View 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;
}

View 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>

View 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 = '';
}

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

View 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>

View 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);
}
}

View 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);
}
}

View 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);
}

View 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;
}
}
}

View 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
};

View 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,
};

View 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];
}
}
}
}

View 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;
}
}

View 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
View 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,
};

View 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);
}

View 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;
}
}

View 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
View 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}.`);
}
}
}

View 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;
}
}

View 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
View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,7 @@
export const TouchFlags =
{
IndirectTouch: 1 << 0,
PrimaryTouch: 1 << 4,
Tap: 1 << 5,
OrphanedPrimaryTouch: 1 << 6,
};

View File

@@ -0,0 +1,8 @@
export const TouchPhase = {
None: 0,
Began: 1,
Moved: 2,
Ended: 3,
Canceled: 4,
Stationary: 5
};

View 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;
}
}

View File

@@ -0,0 +1,11 @@
// mock class
export class DOMHTMLVideoElement {
constructor(rect) {
this.rect = rect;
}
getBoundingClientRect() {
return this.rect;
}
}

View 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);
});
});

View 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);
});

View 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);
});
});

View 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 }));
}
}
}
}

View 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);
});
});

View 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;
}

View 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);
});
});

View 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();
});
});

View 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) { }
}

View 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
});
});

View 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);
});
});

View 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;
}