gst-plugins-rs/www/input.js
2022-01-03 22:30:44 +01:00

552 lines
16 KiB
JavaScript

/**
* Copyright 2019 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*global GamepadManager*/
/*eslint no-unused-vars: ["error", { "vars": "local" }]*/
class Input {
/**
* Input handling for WebRTC web app
*
* @constructor
* @param {Element} [element]
* Video element to attach events to
* @param {function} [send]
* Function used to send input events to server.
*/
constructor(element, send) {
/**
* @type {Element}
*/
this.element = element;
/**
* @type {function}
*/
this.send = send;
/**
* @type {boolean}
*/
this.mouseRelative = false;
/**
* @type {Object}
*/
this.m = null;
/**
* @type {Integer}
*/
this.buttonMask = 0;
/**
* @type {Guacamole.Keyboard}
*/
this.keyboard = null;
/**
* @type {GamepadManager}
*/
this.gamepadManager = null;
/**
* @type {Integer}
*/
this.x = 0;
/**
* @type {Integer}
*/
this.y = 0;
/**
* @type {function}
*/
this.onmenuhotkey = null;
/**
* @type {function}
*/
this.onfullscreenhotkey = null;
/**
* @type {function}
*/
this.ongamepadconnected = null;
/**
* @type {function}
*/
this.ongamepaddisconneceted = null;
/**
* List of attached listeners, record keeping used to detach all.
* @type {Array}
*/
this.listeners = [];
/**
* @type {function}
*/
this.onresizeend = null;
// internal variables used by resize start/end functions.
this._rtime = null;
this._rtimeout = false;
this._rdelta = 200;
}
/**
* Handles mouse button and motion events and sends them to WebRTC app.
* @param {MouseEvent} event
*/
_mouseButtonMovement(event) {
const down = (event.type === 'mousedown' ? 1 : 0);
var mtype = "m";
if (event.type === 'mousemove' && !this.m) return;
if (!document.pointerLockElement) {
if (this.mouseRelative)
event.target.requestPointerLock();
}
// Hotkey to enable pointer lock, CTRL-SHIFT-LeftButton
if (down && event.button === 0 && event.ctrlKey && event.shiftKey) {
event.target.requestPointerLock();
return;
}
if (document.pointerLockElement) {
mtype = "m2";
this.x = event.movementX;
this.y = event.movementY;
} else if (event.type === 'mousemove') {
this.x = this._clientToServerX(event.clientX);
this.y = this._clientToServerY(event.clientY);
}
if (event.type === 'mousedown' || event.type === 'mouseup') {
var mask = 1 << event.button;
if (down) {
this.buttonMask |= mask;
} else {
this.buttonMask &= ~mask;
}
}
var toks = [
mtype,
this.x,
this.y,
this.buttonMask
];
this.send(toks.join(","));
}
/**
* Handles touch events and sends them to WebRTC app.
* @param {TouchEvent} event
*/
_touch(event) {
var mtype = "m";
var mask = 1;
if (event.type === 'touchstart') {
this.buttonMask |= mask;
} else if (event.type === 'touchend') {
this.buttonMask &= ~mask;
} else if (event.type === 'touchmove') {
event.preventDefault();
}
this.x = this._clientToServerX(event.changedTouches[0].clientX);
this.y = this._clientToServerY(event.changedTouches[0].clientY);
var toks = [
mtype,
this.x,
this.y,
this.buttonMask
];
this.send(toks.join(","));
}
/**
* Handles mouse wheel events and sends them to WebRTC app.
* @param {MouseWheelEvent} event
*/
_mouseWheel(event) {
var mtype = (document.pointerLockElement ? "m2" : "m");
var button = 3;
if (event.deltaY < 0) {
button = 4;
}
var mask = 1 << button;
var toks;
// Simulate button press and release.
for (var i = 0; i < 2; i++) {
if (i === 0)
this.buttonMask |= mask;
else
this.buttonMask &= ~mask;
toks = [
mtype,
this.x,
this.y,
this.buttonMask
];
this.send(toks.join(","));
}
event.preventDefault();
}
/**
* Captures mouse context menu (right-click) event and prevents event propagation.
* @param {MouseEvent} event
*/
_contextMenu(event) {
event.preventDefault();
}
/**
* Captures keyboard events to detect pressing of CTRL-SHIFT hotkey.
* @param {KeyboardEvent} event
*/
_key(event) {
// disable problematic browser shortcuts
if (event.code === 'F5' && event.ctrlKey ||
event.code === 'KeyI' && event.ctrlKey && event.shiftKey ||
event.code === 'F11') {
event.preventDefault();
return;
}
// capture menu hotkey
if (event.type === 'keydown' && event.code === 'KeyM' && event.ctrlKey && event.shiftKey) {
if (document.fullscreenElement === null && this.onmenuhotkey !== null) {
this.onmenuhotkey();
event.preventDefault();
}
return;
}
// capture fullscreen hotkey
if (event.type === 'keydown' && event.code === 'KeyF' && event.ctrlKey && event.shiftKey) {
if (document.fullscreenElement === null && this.onfullscreenhotkey !== null) {
this.onfullscreenhotkey();
event.preventDefault();
}
return;
}
}
/**
* Sends WebRTC app command to toggle display of the remote mouse pointer.
*/
_pointerLock() {
if (document.pointerLockElement) {
this.send("p,1");
} else {
this.send("p,0");
}
}
/**
* Sends WebRTC app command to hide the remote pointer when exiting pointer lock.
*/
_exitPointerLock() {
document.exitPointerLock();
// hide the pointer.
this.send("p,0");
}
/**
* Captures display and video dimensions required for computing mouse pointer position.
* This should be fired whenever the window size changes.
*/
_windowMath() {
const windowW = this.element.offsetWidth;
const windowH = this.element.offsetHeight;
const frameW = this.element.videoWidth;
const frameH = this.element.videoHeight;
const multi = Math.min(windowW / frameW, windowH / frameH);
const vpWidth = frameW * multi;
const vpHeight = (frameH * multi);
this.m = {
mouseMultiX: frameW / vpWidth,
mouseMultiY: frameH / vpHeight,
mouseOffsetX: Math.max((windowW - vpWidth) / 2.0, 0),
mouseOffsetY: Math.max((windowH - vpHeight) / 2.0, 0),
centerOffsetX: (document.documentElement.clientWidth - this.element.offsetWidth) / 2.0,
centerOffsetY: (document.documentElement.clientHeight - this.element.offsetHeight) / 2.0,
scrollX: window.scrollX,
scrollY: window.scrollY,
frameW,
frameH,
};
}
/**
* Translates pointer position X based on current window math.
* @param {Integer} clientX
*/
_clientToServerX(clientX) {
let serverX = Math.round((clientX - this.m.mouseOffsetX - this.m.centerOffsetX + this.m.scrollX) * this.m.mouseMultiX);
if (serverX === this.m.frameW - 1) serverX = this.m.frameW;
if (serverX > this.m.frameW) serverX = this.m.frameW;
if (serverX < 0) serverX = 0;
return serverX;
}
/**
* Translates pointer position Y based on current window math.
* @param {Integer} clientY
*/
_clientToServerY(clientY) {
let serverY = Math.round((clientY - this.m.mouseOffsetY - this.m.centerOffsetY + this.m.scrollY) * this.m.mouseMultiY);
if (serverY === this.m.frameH - 1) serverY = this.m.frameH;
if (serverY > this.m.frameH) serverY = this.m.frameH;
if (serverY < 0) serverY = 0;
return serverY;
}
/**
* Sends command to WebRTC app to connect virtual joystick and initializes the local GamepadManger.
* @param {GamepadEvent} event
*/
_gamepadConnected(event) {
console.log("Gamepad connected at index %d: %s. %d buttons, %d axes.",
event.gamepad.index, event.gamepad.id,
event.gamepad.buttons.length, event.gamepad.axes.length);
if (this.ongamepadconnected !== null) {
this.ongamepadconnected(event.gamepad.id);
}
// Initialize the gamepad manager.
this.gamepadManager = new GamepadManager(event.gamepad, this._gamepadButton.bind(this), this._gamepadAxis.bind(this), this._gamepadDisconnect.bind(this));
// Send joystick connect message over data channel.
this.send("js,c," + this.gamepadManager.numAxes + "," + this.gamepadManager.numButtons);
}
/**
* Sends joystick disconnect command to WebRTC app.
*/
_gamepadDisconnect() {
console.log("Gamepad disconnected");
if (this.ongamepaddisconneceted !== null) {
this.ongamepaddisconneceted();
}
this.send("js,d")
}
/**
* Send gamepad button to WebRTC app.
*
* @param {number} gp_num - the gamepad number
* @param {number} btn_num - the uinput converted button number
* @param {number} val - the button value, 1 or 0 for pressed or not-pressed.
*/
_gamepadButton(gp_num, btn_num, val) {
this.send("js,b," + btn_num + "," + val);
}
/**
* Send the gamepad axis to the WebRTC app.
*
* @param {number} gp_num - the gamepad number
* @param {number} axis_num - the uinput converted axis number
* @param {number} val - the normalize value between [0, 255]
*/
_gamepadAxis(gp_num, axis_num, val) {
this.send("js,a," + axis_num + "," + val)
}
/**
* When fullscreen is entered, request keyboard and pointer lock.
*/
_onFullscreenChange() {
if (document.fullscreenElement !== null) {
// Enter fullscreen
this.requestKeyboardLock();
this.element.requestPointerLock();
}
// Reset local keyboard. When holding to exit full-screen the escape key can get stuck.
this.keyboard.reset();
// Reset stuck keys on server side.
this.send("kr");
}
/**
* Called when window is being resized, used to detect when resize ends so new resolution can be sent.
*/
_resizeStart() {
this._rtime = new Date();
if (this._rtimeout === false) {
this._rtimeout = true;
setTimeout(() => { this._resizeEnd() }, this._rdelta);
}
}
/**
* Called in setTimeout loop to detect if window is done being resized.
*/
_resizeEnd() {
if (new Date() - this._rtime < this._rdelta) {
setTimeout(() => { this._resizeEnd() }, this._rdelta);
} else {
this._rtimeout = false;
if (this.onresizeend !== null) {
this.onresizeend();
}
}
}
/**
* Attaches input event handles to docuemnt, window and element.
*/
attach() {
this.listeners.push(addListener(this.element, 'resize', this._windowMath, this));
this.listeners.push(addListener(this.element, 'mousewheel', this._mouseWheel, this));
this.listeners.push(addListener(this.element, 'contextmenu', this._contextMenu, this));
this.listeners.push(addListener(this.element.parentElement, 'fullscreenchange', this._onFullscreenChange, this));
this.listeners.push(addListener(document, 'pointerlockchange', this._pointerLock, this));
this.listeners.push(addListener(window, 'keydown', this._key, this));
this.listeners.push(addListener(window, 'keyup', this._key, this));
this.listeners.push(addListener(window, 'resize', this._windowMath, this));
this.listeners.push(addListener(window, 'resize', this._resizeStart, this));
// Gamepad support
this.listeners.push(addListener(window, 'gamepadconnected', this._gamepadConnected, this));
this.listeners.push(addListener(window, 'gamepaddisconnected', this._gamepadDisconnect, this));
if ('ontouchstart' in window) {
this.listeners.push(addListener(window, 'touchstart', this._touch, this));
this.listeners.push(addListener(this.element, 'touchend', this._touch, this));
this.listeners.push(addListener(this.element, 'touchmove', this._touch, this));
console.log("Enabling mouse pointer display for touch devices.");
this.send("p,1");
} else {
this.listeners.push(addListener(this.element, 'mousemove', this._mouseButtonMovement, this));
this.listeners.push(addListener(this.element, 'mousedown', this._mouseButtonMovement, this));
this.listeners.push(addListener(this.element, 'mouseup', this._mouseButtonMovement, this));
}
// Adjust for scroll offset
this.listeners.push(addListener(window, 'scroll', () => {
this.m.scrollX = window.scrollX;
this.m.scrollY = window.scrollY;
}, this));
// Using guacamole keyboard because it has the keysym translations.
this.keyboard = new Guacamole.Keyboard(window);
this.keyboard.onkeydown = (keysym) => {
this.send("kd," + keysym);
};
this.keyboard.onkeyup = (keysym) => {
this.send("ku," + keysym);
};
this._windowMath();
}
detach() {
removeListeners(this.listeners);
this._exitPointerLock();
if (this.keyboard) {
this.keyboard.onkeydown = null;
this.keyboard.onkeyup = null;
this.keyboard.reset();
delete this.keyboard;
this.send("kr");
}
}
/**
* Request keyboard lock, must be in fullscreen mode to work.
*/
requestKeyboardLock() {
// event codes: https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system
const keys = [
"AltLeft",
"AltRight",
"Tab",
"Escape",
"ContextMenu",
"MetaLeft",
"MetaRight"
];
console.log("requesting keyboard lock");
navigator.keyboard.lock(keys).then(
() => {
console.log("keyboard lock success");
}
).catch(
(e) => {
console.log("keyboard lock failed: ", e);
}
)
}
getWindowResolution() {
return [
parseInt(this.element.offsetWidth * window.devicePixelRatio),
parseInt(this.element.offsetHeight * window.devicePixelRatio)
];
}
}
/**
* Helper function to keep track of attached event listeners.
* @param {Object} obj
* @param {string} name
* @param {function} func
* @param {Object} ctx
*/
function addListener(obj, name, func, ctx) {
const newFunc = ctx ? func.bind(ctx) : func;
obj.addEventListener(name, newFunc);
return [obj, name, newFunc];
}
/**
* Helper function to remove all attached event listeners.
* @param {Array} listeners
*/
function removeListeners(listeners) {
for (const listener of listeners)
listener[0].removeEventListener(listener[1], listener[2]);
}