Merge pull request #1192 from jitsi/remotecontrol

Implement remote control support
This commit is contained in:
Paweł Domas 2017-01-23 17:00:11 -06:00 committed by GitHub
commit c0e80c14f8
21 changed files with 1264 additions and 77 deletions

4
ConferenceEvents.js Normal file
View File

@ -0,0 +1,4 @@
/**
* Notifies interested parties that hangup procedure will start.
*/
export const BEFORE_HANGUP = "conference.before_hangup";

4
app.js
View File

@ -22,6 +22,7 @@ import conference from './conference';
import API from './modules/API/API';
import translation from "./modules/translation/translation";
import remoteControl from "./modules/remotecontrol/RemoteControl";
const APP = {
// Used by do_external_connect.js if we receive the attach data after
@ -59,7 +60,8 @@ const APP = {
*/
ConferenceUrl : null,
connection: null,
API
API,
remoteControl
};
// TODO The execution of the mobile app starts from react/index.native.js.

View File

@ -14,9 +14,12 @@ import {reportError} from './modules/util/helpers';
import UIEvents from './service/UI/UIEvents';
import UIUtil from './modules/UI/util/UIUtil';
import * as JitsiMeetConferenceEvents from './ConferenceEvents';
import analytics from './modules/analytics/analytics';
import EventEmitter from "events";
const ConnectionEvents = JitsiMeetJS.events.connection;
const ConnectionErrors = JitsiMeetJS.errors.connection;
@ -28,6 +31,8 @@ const TrackErrors = JitsiMeetJS.errors.track;
const ConnectionQualityEvents = JitsiMeetJS.events.connectionQuality;
const eventEmitter = new EventEmitter();
let room, connection, localAudio, localVideo;
/**
@ -485,10 +490,11 @@ export default {
}).then(([tracks, con]) => {
logger.log('initialized with %s local tracks', tracks.length);
APP.connection = connection = con;
this._bindConnectionFailedHandler(con);
this._createRoom(tracks);
this.isDesktopSharingEnabled =
JitsiMeetJS.isDesktopSharingEnabled();
APP.remoteControl.init();
this._bindConnectionFailedHandler(con);
this._createRoom(tracks);
if (UIUtil.isButtonEnabled('contacts')
&& !interfaceConfig.filmStripOnly) {
@ -981,7 +987,7 @@ export default {
let externalInstallation = false;
if (shareScreen) {
createLocalTracks({
this.screenSharingPromise = createLocalTracks({
devices: ['desktop'],
desktopSharingExtensionExternalInstallation: {
interval: 500,
@ -1070,7 +1076,10 @@ export default {
dialogTitleKey, dialogTxt, false);
});
} else {
createLocalTracks({ devices: ['video'] }).then(
APP.remoteControl.receiver.stop();
this.screenSharingPromise = createLocalTracks(
{ devices: ['video'] })
.then(
([stream]) => this.useVideoStream(stream)
).then(() => {
this.videoSwitchInProgress = false;
@ -1102,6 +1111,8 @@ export default {
}
);
room.on(ConferenceEvents.PARTCIPANT_FEATURES_CHANGED,
user => APP.UI.onUserFeaturesChanged(user));
room.on(ConferenceEvents.USER_JOINED, (id, user) => {
if (user.isHidden())
return;
@ -1600,12 +1611,23 @@ export default {
},
/**
* Adds any room listener.
* @param eventName one of the ConferenceEvents
* @param callBack the function to be called when the event occurs
* @param {string} eventName one of the ConferenceEvents
* @param {Function} listener the function to be called when the event
* occurs
*/
addConferenceListener(eventName, callBack) {
room.on(eventName, callBack);
addConferenceListener(eventName, listener) {
room.on(eventName, listener);
},
/**
* Removes any room listener.
* @param {string} eventName one of the ConferenceEvents
* @param {Function} listener the listener to be removed.
*/
removeConferenceListener(eventName, listener) {
room.off(eventName, listener);
},
/**
* Inits list of current devices and event listener for device change.
* @private
@ -1763,6 +1785,7 @@ export default {
* requested
*/
hangup (requestFeedback = false) {
eventEmitter.emit(JitsiMeetConferenceEvents.BEFORE_HANGUP);
APP.UI.hideRingOverLay();
let requestFeedbackPromise = requestFeedback
? APP.UI.requestFeedbackOnHangup()
@ -1813,5 +1836,36 @@ export default {
APP.settings.setAvatarUrl(url);
APP.UI.setUserAvatarUrl(room.myUserId(), url);
sendData(commands.AVATAR_URL, url);
},
/**
* Sends a message via the data channel.
* @param {string} to the id of the endpoint that should receive the
* message. If "" - the message will be sent to all participants.
* @param {object} payload the payload of the message.
* @throws NetworkError or InvalidStateError or Error if the operation
* fails.
*/
sendEndpointMessage (to, payload) {
room.sendEndpointMessage(to, payload);
},
/**
* Adds new listener.
* @param {String} eventName the name of the event
* @param {Function} listener the listener.
*/
addListener (eventName, listener) {
eventEmitter.addListener(eventName, listener);
},
/**
* Removes listener.
* @param {String} eventName the name of the event that triggers the
* listener
* @param {Function} listener the listener.
*/
removeListener (eventName, listener) {
eventEmitter.removeListener(eventName, listener);
}
};

View File

@ -92,7 +92,7 @@
0 0 3px $videoThumbnailSelected !important;
}
.remotevideomenu {
.remotevideomenu > .icon-menu {
display: none;
}
@ -105,7 +105,7 @@
box-shadow: inset 0 0 3px $videoThumbnailHovered,
0 0 3px $videoThumbnailHovered;
.remotevideomenu {
.remotevideomenu > .icon-menu {
display: inline-block;
}
}
@ -121,4 +121,4 @@
}
}
}
}
}

View File

@ -6,7 +6,6 @@
padding: 0;
margin: 2px 0;
bottom: 0;
width: 100px;
height: auto;
&:first-child {
@ -66,4 +65,9 @@
span.remotevideomenu:hover ul.popupmenu, ul.popupmenu:hover {
display:block !important;
}
}
.remote-control-spinner {
top: 6px;
left: 2px;
}

View File

@ -156,8 +156,8 @@
"kick": "Kick out",
"muted": "Muted",
"domute": "Mute",
"flip": "Flip"
"flip": "Flip",
"remoteControl": "Remote control"
},
"connectionindicator":
{
@ -316,7 +316,12 @@
"externalInstallationMsg": "You need to install our desktop sharing extension.",
"muteParticipantTitle": "Mute this participant?",
"muteParticipantBody": "You won't be able to unmute them, but they can unmute themselves at any time.",
"muteParticipantButton": "Mute"
"muteParticipantButton": "Mute",
"remoteControlTitle": "Remote Control",
"remoteControlDeniedMessage": "__user__ rejected your remote control request!",
"remoteControlAllowedMessage": "__user__ accepted your remote control request!",
"remoteControlErrorMessage": "An error occurred while trying to request remote control permissions from __user__!",
"remoteControlStopMessage": "The remote control session ended!"
},
"email":
{

View File

@ -55,7 +55,9 @@ function initCommands() {
APP.conference.toggleScreenSharing.bind(APP.conference),
"video-hangup": () => APP.conference.hangup(),
"email": APP.conference.changeLocalEmail,
"avatar-url": APP.conference.changeLocalAvatarUrl
"avatar-url": APP.conference.changeLocalAvatarUrl,
"remote-control-event": event =>
APP.remoteControl.onRemoteControlAPIEvent(event)
};
Object.keys(commands).forEach(function (key) {
postis.listen(key, args => commands[key](...args));
@ -94,7 +96,13 @@ function triggerEvent (name, object) {
}
}
export default {
class API {
/**
* Constructs new instance
* @constructor
*/
constructor() { }
/**
* Initializes the APIConnector. Setups message event listeners that will
* receive information from external applications that embed Jitsi Meet.
@ -108,6 +116,17 @@ export default {
return;
enabled = true;
if(!postis) {
this._initPostis();
}
}
/**
* initializes postis library.
* @private
*/
_initPostis() {
let postisOptions = {
window: target
};
@ -116,7 +135,7 @@ export default {
= "jitsi_meet_external_api_" + jitsi_meet_external_api_id;
postis = postisInit(postisOptions);
initCommands();
},
}
/**
* Notify external application (if API is enabled) that message was sent.
@ -124,7 +143,7 @@ export default {
*/
notifySendingChatMessage (body) {
triggerEvent("outgoing-message", {"message": body});
},
}
/**
* Notify external application (if API is enabled) that
@ -143,7 +162,7 @@ export default {
"incoming-message",
{"from": id, "nick": nick, "message": body, "stamp": ts}
);
},
}
/**
* Notify external application (if API is enabled) that
@ -152,7 +171,7 @@ export default {
*/
notifyUserJoined (id) {
triggerEvent("participant-joined", {id});
},
}
/**
* Notify external application (if API is enabled) that
@ -161,7 +180,7 @@ export default {
*/
notifyUserLeft (id) {
triggerEvent("participant-left", {id});
},
}
/**
* Notify external application (if API is enabled) that
@ -171,7 +190,7 @@ export default {
*/
notifyDisplayNameChanged (id, displayName) {
triggerEvent("display-name-change", {id, displayname: displayName});
},
}
/**
* Notify external application (if API is enabled) that
@ -181,7 +200,7 @@ export default {
*/
notifyConferenceJoined (room) {
triggerEvent("video-conference-joined", {roomName: room});
},
}
/**
* Notify external application (if API is enabled) that
@ -191,7 +210,7 @@ export default {
*/
notifyConferenceLeft (room) {
triggerEvent("video-conference-left", {roomName: room});
},
}
/**
* Notify external application (if API is enabled) that
@ -199,13 +218,23 @@ export default {
*/
notifyReadyToClose () {
triggerEvent("video-ready-to-close", {});
},
}
/**
* Sends remote control event.
* @param {RemoteControlEvent} event the remote control event.
*/
sendRemoteControlEvent(event) {
sendMessage({method: "remote-control-event", params: event});
}
/**
* Removes the listeners.
*/
dispose: function () {
dispose () {
if(enabled)
postis.destroy();
}
};
}
export default new API();

View File

@ -1441,4 +1441,11 @@ UI.hideUserMediaPermissionsGuidanceOverlay = function () {
GumPermissionsOverlay.hide();
};
/**
* Handles user's features changes.
*/
UI.onUserFeaturesChanged = function (user) {
VideoLayout.onUserFeaturesChanged(user);
};
module.exports = UI;

View File

@ -3,6 +3,7 @@ const logger = require("jitsi-meet-logger").getLogger(__filename);
import Avatar from "../avatar/Avatar";
import {createDeferred} from '../../util/helpers';
import UIEvents from "../../../service/UI/UIEvents";
import UIUtil from "../util/UIUtil";
import {VideoContainer, VIDEO_CONTAINER_TYPE} from "./VideoContainer";
@ -19,6 +20,7 @@ export default class LargeVideoManager {
* @type {Object.<string, LargeContainer>}
*/
this.containers = {};
this.eventEmitter = emitter;
this.state = VIDEO_CONTAINER_TYPE;
this.videoContainer = new VideoContainer(
@ -164,6 +166,7 @@ export default class LargeVideoManager {
// after everything is done check again if there are any pending
// new streams.
this.updateInProcess = false;
this.eventEmitter.emit(UIEvents.LARGE_VIDEO_ID_CHANGED, this.id);
this.scheduleLargeVideoUpdate();
});
}

View File

@ -29,6 +29,7 @@ function RemoteVideo(user, VideoLayout, emitter) {
this.videoSpanId = `participant_${this.id}`;
SmallVideo.call(this, VideoLayout);
this.hasRemoteVideoMenu = false;
this._supportsRemoteControl = false;
this.addRemoteVideoContainer();
this.connectionIndicator = new ConnectionIndicator(this, this.id);
this.setDisplayName();
@ -64,7 +65,7 @@ RemoteVideo.prototype.addRemoteVideoContainer = function() {
this.initBrowserSpecificProperties();
if (APP.conference.isModerator) {
if (APP.conference.isModerator || this._supportsRemoteControl) {
this.addRemoteVideoMenu();
}
@ -106,14 +107,6 @@ RemoteVideo.prototype._initPopupMenu = function (popupMenuElement) {
// call the original show, passing its actual this
origShowFunc.call(this.popover);
}.bind(this);
// override popover hide method so we can cleanup click handlers
let origHideFunc = this.popover.forceHide;
this.popover.forceHide = function () {
$(document).off("click", '#mutelink_' + this.id);
$(document).off("click", '#ejectlink_' + this.id);
origHideFunc.call(this.popover);
}.bind(this);
};
/**
@ -139,38 +132,68 @@ RemoteVideo.prototype._generatePopupContent = function () {
let popupmenuElement = document.createElement('ul');
popupmenuElement.className = 'popupmenu';
popupmenuElement.id = `remote_popupmenu_${this.id}`;
let menuItems = [];
let muteTranslationKey;
let muteClassName;
if (this.isAudioMuted) {
muteTranslationKey = 'videothumbnail.muted';
muteClassName = 'mutelink disabled';
} else {
muteTranslationKey = 'videothumbnail.domute';
muteClassName = 'mutelink';
if(APP.conference.isModerator) {
let muteTranslationKey;
let muteClassName;
if (this.isAudioMuted) {
muteTranslationKey = 'videothumbnail.muted';
muteClassName = 'mutelink disabled';
} else {
muteTranslationKey = 'videothumbnail.domute';
muteClassName = 'mutelink';
}
let muteHandler = this._muteHandler.bind(this);
let kickHandler = this._kickHandler.bind(this);
menuItems = [
{
id: 'mutelink_' + this.id,
handler: muteHandler,
icon: 'icon-mic-disabled',
className: muteClassName,
data: {
i18n: muteTranslationKey
}
}, {
id: 'ejectlink_' + this.id,
handler: kickHandler,
icon: 'icon-kick',
data: {
i18n: 'videothumbnail.kick'
}
}
];
}
let muteHandler = this._muteHandler.bind(this);
let kickHandler = this._kickHandler.bind(this);
let menuItems = [
{
id: 'mutelink_' + this.id,
handler: muteHandler,
icon: 'icon-mic-disabled',
className: muteClassName,
data: {
i18n: muteTranslationKey
}
}, {
id: 'ejectlink_' + this.id,
handler: kickHandler,
icon: 'icon-kick',
data: {
i18n: 'videothumbnail.kick'
}
if(this._supportsRemoteControl) {
let icon, handler, className;
if(APP.remoteControl.controller.getRequestedParticipant()
=== this.id) {
handler = () => {};
className = "requestRemoteControlLink disabled";
icon = "remote-control-spinner fa fa-spinner fa-spin";
} else if(!APP.remoteControl.controller.isStarted()) {
handler = this._requestRemoteControlPermissions.bind(this);
icon = "fa fa-play";
className = "requestRemoteControlLink";
} else {
handler = this._stopRemoteControl.bind(this);
icon = "fa fa-stop";
className = "requestRemoteControlLink";
}
];
menuItems.push({
id: 'remoteControl_' + this.id,
handler,
icon,
className,
data: {
i18n: 'videothumbnail.remoteControl'
}
});
}
menuItems.forEach(el => {
let menuItem = this._generatePopupMenuItem(el);
@ -182,6 +205,76 @@ RemoteVideo.prototype._generatePopupContent = function () {
return popupmenuElement;
};
/**
* Sets the remote control supported value and initializes or updates the menu
* depending on the remote control is supported or not.
* @param {boolean} isSupported
*/
RemoteVideo.prototype.setRemoteControlSupport = function(isSupported = false) {
if(this._supportsRemoteControl === isSupported) {
return;
}
this._supportsRemoteControl = isSupported;
if(!isSupported) {
return;
}
if(!this.hasRemoteVideoMenu) {
//create menu
this.addRemoteVideoMenu();
} else {
//update the content
this.updateRemoteVideoMenu(this.isAudioMuted, true);
}
};
/**
* Requests permissions for remote control session.
*/
RemoteVideo.prototype._requestRemoteControlPermissions = function () {
APP.remoteControl.controller.requestPermissions(
this.id, this.VideoLayout.getLargeVideoWrapper()).then(result => {
if(result === null) {
return;
}
this.updateRemoteVideoMenu(this.isAudioMuted, true);
APP.UI.messageHandler.openMessageDialog(
"dialog.remoteControlTitle",
(result === false) ? "dialog.remoteControlDeniedMessage"
: "dialog.remoteControlAllowedMessage",
{user: this.user.getDisplayName()
|| interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME}
);
if(result === true) {//the remote control permissions has been granted
// pin the controlled participant
let pinnedId = this.VideoLayout.getPinnedId();
if(pinnedId !== this.id) {
this.VideoLayout.handleVideoThumbClicked(this.id);
}
}
}, error => {
logger.error(error);
this.updateRemoteVideoMenu(this.isAudioMuted, true);
APP.UI.messageHandler.openMessageDialog(
"dialog.remoteControlTitle",
"dialog.remoteControlErrorMessage",
{user: this.user.getDisplayName()
|| interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME}
);
});
this.updateRemoteVideoMenu(this.isAudioMuted, true);
};
/**
* Stops remote control session.
*/
RemoteVideo.prototype._stopRemoteControl = function () {
// send message about stopping
APP.remoteControl.controller.stop();
this.updateRemoteVideoMenu(this.isAudioMuted, true);
};
RemoteVideo.prototype._muteHandler = function () {
if (this.isAudioMuted)
return;
@ -244,8 +337,7 @@ RemoteVideo.prototype._generatePopupMenuItem = function (opts = {}) {
linkItem.appendChild(textContent);
linkItem.id = id;
// Delegate event to the document.
$(document).on("click", `#${id}`, handler);
linkItem.onclick = handler;
menuItem.appendChild(linkItem);
return menuItem;

View File

@ -406,6 +406,7 @@ var VideoLayout = {
remoteVideo = smallVideo;
else
remoteVideo = new RemoteVideo(user, VideoLayout, eventEmitter);
this._setRemoteControlProperties(user, remoteVideo);
this.addRemoteVideoContainer(id, remoteVideo);
},
@ -1158,12 +1159,44 @@ var VideoLayout = {
* Sets the flipX state of the local video.
* @param {boolean} true for flipped otherwise false;
*/
setLocalFlipX: function (val) {
setLocalFlipX (val) {
this.localFlipX = val;
},
getEventEmitter: () => {return eventEmitter;}
getEventEmitter() {return eventEmitter;},
/**
* Handles user's features changes.
*/
onUserFeaturesChanged (user) {
let video = this.getSmallVideo(user.getId());
if (!video) {
return;
}
this._setRemoteControlProperties(user, video);
},
/**
* Sets the remote control properties (checks whether remote control
* is supported and executes remoteVideo.setRemoteControlSupport).
* @param {JitsiParticipant} user the user that will be checked for remote
* control support.
* @param {RemoteVideo} remoteVideo the remoteVideo on which the properties
* will be set.
*/
_setRemoteControlProperties (user, remoteVideo) {
APP.remoteControl.checkUserRemoteControlSupport(user).then(result =>
remoteVideo.setRemoteControlSupport(result));
},
/**
* Returns the wrapper jquery selector for the largeVideo
* @returns {JQuerySelector} the wrapper jquery selector for the largeVideo
*/
getLargeVideoWrapper() {
return this.getCurrentlyOnLargeContainer().$wrapper;
}
};
export default VideoLayout;

View File

@ -65,6 +65,12 @@ function showKeyboardShortcutsPanel(show) {
*/
let _shortcuts = {};
/**
* True if the keyboard shortcuts are enabled and false if not.
* @type {boolean}
*/
let enabled = true;
/**
* Maps keycode to character, id of popover for given function and function.
*/
@ -74,6 +80,9 @@ var KeyboardShortcut = {
var self = this;
window.onkeyup = function(e) {
if(!enabled) {
return;
}
var key = self._getKeyboardKey(e).toUpperCase();
var num = parseInt(key, 10);
if(!($(":focus").is("input[type=text]") ||
@ -93,6 +102,9 @@ var KeyboardShortcut = {
};
window.onkeydown = function(e) {
if(!enabled) {
return;
}
if(!($(":focus").is("input[type=text]") ||
$(":focus").is("input[type=password]") ||
$(":focus").is("textarea"))) {
@ -105,6 +117,14 @@ var KeyboardShortcut = {
};
},
/**
* Enables/Disables the keyboard shortcuts.
* @param {boolean} value - the new value.
*/
enable: function (value) {
enabled = value;
},
/**
* Registers a new shortcut.
*

163
modules/keycode/keycode.js Normal file
View File

@ -0,0 +1,163 @@
/**
* Enumerates the supported keys.
* NOTE: The maps represents physical keys on the keyboard, not chars.
* @readonly
* @enum {string}
*/
export const KEYS = {
BACKSPACE: "backspace" ,
DELETE : "delete",
RETURN : "enter",
TAB : "tab",
ESCAPE : "escape",
UP : "up",
DOWN : "down",
RIGHT : "right",
LEFT : "left",
HOME : "home",
END : "end",
PAGEUP : "pageup",
PAGEDOWN : "pagedown",
F1 : "f1",
F2 : "f2",
F3 : "f3",
F4 : "f4",
F5 : "f5",
F6 : "f6",
F7 : "f7",
F8 : "f8",
F9 : "f9",
F10 : "f10",
F11 : "f11",
F12 : "f12",
META : "command",
CMD_L: "command",
CMD_R: "command",
ALT : "alt",
CONTROL : "control",
SHIFT : "shift",
CAPS_LOCK: "caps_lock", //not supported by robotjs
SPACE : "space",
PRINTSCREEN : "printscreen",
INSERT : "insert",
NUMPAD_0 : "numpad_0",
NUMPAD_1 : "numpad_1",
NUMPAD_2 : "numpad_2",
NUMPAD_3 : "numpad_3",
NUMPAD_4 : "numpad_4",
NUMPAD_5 : "numpad_5",
NUMPAD_6 : "numpad_6",
NUMPAD_7 : "numpad_7",
NUMPAD_8 : "numpad_8",
NUMPAD_9 : "numpad_9",
COMMA: ",",
PERIOD: ".",
SEMICOLON: ";",
QUOTE: "'",
BRACKET_LEFT: "[",
BRACKET_RIGHT: "]",
BACKQUOTE: "`",
BACKSLASH: "\\",
MINUS: "-",
EQUAL: "=",
SLASH: "/"
};
/**
* Mapping between the key codes and keys deined in KEYS.
* The mappings are based on
* https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode#Specifications
*/
let keyCodeToKey = {
8: KEYS.BACKSPACE,
9: KEYS.TAB,
13: KEYS.RETURN,
16: KEYS.SHIFT,
17: KEYS.CONTROL,
18: KEYS.ALT,
20: KEYS.CAPS_LOCK,
27: KEYS.ESCAPE,
32: KEYS.SPACE,
33: KEYS.PAGEUP,
34: KEYS.PAGEDOWN,
35: KEYS.END,
36: KEYS.HOME,
37: KEYS.LEFT,
38: KEYS.UP,
39: KEYS.RIGHT,
40: KEYS.DOWN,
42: KEYS.PRINTSCREEN,
44: KEYS.PRINTSCREEN,
45: KEYS.INSERT,
46: KEYS.DELETE,
59: KEYS.SEMICOLON,
61: KEYS.EQUAL,
91: KEYS.CMD_L,
92: KEYS.CMD_R,
93: KEYS.CMD_R,
96: KEYS.NUMPAD_0,
97: KEYS.NUMPAD_1,
98: KEYS.NUMPAD_2,
99: KEYS.NUMPAD_3,
100: KEYS.NUMPAD_4,
101: KEYS.NUMPAD_5,
102: KEYS.NUMPAD_6,
103: KEYS.NUMPAD_7,
104: KEYS.NUMPAD_8,
105: KEYS.NUMPAD_9,
112: KEYS.F1,
113: KEYS.F2,
114: KEYS.F3,
115: KEYS.F4,
116: KEYS.F5,
117: KEYS.F6,
118: KEYS.F7,
119: KEYS.F8,
120: KEYS.F9,
121: KEYS.F10,
122: KEYS.F11,
123: KEYS.F12,
124: KEYS.PRINTSCREEN,
173: KEYS.MINUS,
186: KEYS.SEMICOLON,
187: KEYS.EQUAL,
188: KEYS.COMMA,
189: KEYS.MINUS,
190: KEYS.PERIOD,
191: KEYS.SLASH,
192: KEYS.BACKQUOTE,
219: KEYS.BRACKET_LEFT,
220: KEYS.BACKSLASH,
221: KEYS.BRACKET_RIGHT,
222: KEYS.QUOTE,
224: KEYS.META,
229: KEYS.SEMICOLON
};
/**
* Generate codes for digit keys (0-9)
*/
for(let i = 0; i < 10; i++) {
keyCodeToKey[i + 48] = `${i}`;
}
/**
* Generate codes for letter keys (a-z)
*/
for(let i = 0; i < 26; i++) {
let keyCode = i + 65;
keyCodeToKey[keyCode] = String.fromCharCode(keyCode).toLowerCase();
}
/**
* Returns key associated with the keyCode from the passed event.
* @param {KeyboardEvent} event the event
* @returns {KEYS} the key on the keyboard.
*/
export function keyboardEventToKey(event) {
return keyCodeToKey[event.which];
}

View File

@ -0,0 +1,374 @@
/* global $, JitsiMeetJS, APP */
const logger = require("jitsi-meet-logger").getLogger(__filename);
import * as KeyCodes from "../keycode/keycode";
import {EVENT_TYPES, REMOTE_CONTROL_EVENT_TYPE, PERMISSIONS_ACTIONS}
from "../../service/remotecontrol/Constants";
import RemoteControlParticipant from "./RemoteControlParticipant";
import UIEvents from "../../service/UI/UIEvents";
const ConferenceEvents = JitsiMeetJS.events.conference;
/**
* Extract the keyboard key from the keyboard event.
* @param event {KeyboardEvent} the event.
* @returns {KEYS} the key that is pressed or undefined.
*/
function getKey(event) {
return KeyCodes.keyboardEventToKey(event);
}
/**
* Extract the modifiers from the keyboard event.
* @param event {KeyboardEvent} the event.
* @returns {Array} with possible values: "shift", "control", "alt", "command".
*/
function getModifiers(event) {
let modifiers = [];
if(event.shiftKey) {
modifiers.push("shift");
}
if(event.ctrlKey) {
modifiers.push("control");
}
if(event.altKey) {
modifiers.push("alt");
}
if(event.metaKey) {
modifiers.push("command");
}
return modifiers;
}
/**
* This class represents the controller party for a remote controller session.
* It listens for mouse and keyboard events and sends them to the receiver
* party of the remote control session.
*/
export default class Controller extends RemoteControlParticipant {
/**
* Creates new instance.
*/
constructor() {
super();
this.isCollectingEvents = false;
this.controlledParticipant = null;
this.requestedParticipant = null;
this._stopListener = this._handleRemoteControlStoppedEvent.bind(this);
this._userLeftListener = this._onUserLeft.bind(this);
this._largeVideoChangedListener
= this._onLargeVideoIdChanged.bind(this);
}
/**
* Requests permissions from the remote control receiver side.
* @param {string} userId the user id of the participant that will be
* requested.
* @param {JQuerySelector} eventCaptureArea the area that is going to be
* used mouse and keyboard event capture.
* @returns {Promise<boolean>} - resolve values:
* true - accept
* false - deny
* null - the participant has left.
*/
requestPermissions(userId, eventCaptureArea) {
if(!this.enabled) {
return Promise.reject(new Error("Remote control is disabled!"));
}
this.area = eventCaptureArea;// $("#largeVideoWrapper")
logger.log("Requsting remote control permissions from: " + userId);
return new Promise((resolve, reject) => {
const clearRequest = () => {
this.requestedParticipant = null;
APP.conference.removeConferenceListener(
ConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
permissionsReplyListener);
APP.conference.removeConferenceListener(
ConferenceEvents.USER_LEFT,
onUserLeft);
};
const permissionsReplyListener = (participant, event) => {
let result = null;
try {
result = this._handleReply(participant, event);
} catch (e) {
reject(e);
}
if(result !== null) {
clearRequest();
resolve(result);
}
};
const onUserLeft = (id) => {
if(id === this.requestedParticipant) {
clearRequest();
resolve(null);
}
};
APP.conference.addConferenceListener(
ConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
permissionsReplyListener);
APP.conference.addConferenceListener(ConferenceEvents.USER_LEFT,
onUserLeft);
this.requestedParticipant = userId;
this._sendRemoteControlEvent(userId, {
type: EVENT_TYPES.permissions,
action: PERMISSIONS_ACTIONS.request
}, e => {
clearRequest();
reject(e);
});
});
}
/**
* Handles the reply of the permissions request.
* @param {JitsiParticipant} participant the participant that has sent the
* reply
* @param {RemoteControlEvent} event the remote control event.
*/
_handleReply(participant, event) {
const remoteControlEvent = event.event;
const userId = participant.getId();
if(this.enabled && event.type === REMOTE_CONTROL_EVENT_TYPE
&& remoteControlEvent.type === EVENT_TYPES.permissions
&& userId === this.requestedParticipant) {
if(remoteControlEvent.action !== PERMISSIONS_ACTIONS.grant) {
this.area = null;
}
switch(remoteControlEvent.action) {
case PERMISSIONS_ACTIONS.grant: {
this.controlledParticipant = userId;
logger.log("Remote control permissions granted to: "
+ userId);
this._start();
return true;
}
case PERMISSIONS_ACTIONS.deny:
return false;
case PERMISSIONS_ACTIONS.error:
throw new Error("Error occurred on receiver side");
default:
throw new Error("Unknown reply received!");
}
} else {
//different message type or another user -> ignoring the message
return null;
}
}
/**
* Handles remote control stopped.
* @param {JitsiParticipant} participant the participant that has sent the
* event
* @param {Object} event EndpointMessage event from the data channels.
* @property {string} type property. The function process only events of
* type REMOTE_CONTROL_EVENT_TYPE
* @property {RemoteControlEvent} event - the remote control event.
*/
_handleRemoteControlStoppedEvent(participant, event) {
if(this.enabled && event.type === REMOTE_CONTROL_EVENT_TYPE
&& event.event.type === EVENT_TYPES.stop
&& participant.getId() === this.controlledParticipant) {
this._stop();
}
}
/**
* Starts processing the mouse and keyboard events. Sets conference
* listeners. Disables keyboard events.
*/
_start() {
logger.log("Starting remote control controller.");
APP.UI.addListener(UIEvents.LARGE_VIDEO_ID_CHANGED,
this._largeVideoChangedListener);
APP.conference.addConferenceListener(
ConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
this._stopListener);
APP.conference.addConferenceListener(ConferenceEvents.USER_LEFT,
this._userLeftListener);
this.resume();
}
/**
* Disables the keyboatd shortcuts. Starts collecting remote control
* events.
*
* It can be used to resume an active remote control session wchich was
* paused with this.pause().
*/
resume() {
if(!this.enabled || this.isCollectingEvents) {
return;
}
logger.log("Resuming remote control controller.");
this.isCollectingEvents = true;
APP.keyboardshortcut.enable(false);
this.area.mousemove(event => {
const position = this.area.position();
this._sendRemoteControlEvent(this.controlledParticipant, {
type: EVENT_TYPES.mousemove,
x: (event.pageX - position.left)/this.area.width(),
y: (event.pageY - position.top)/this.area.height()
});
});
this.area.mousedown(this._onMouseClickHandler.bind(this,
EVENT_TYPES.mousedown));
this.area.mouseup(this._onMouseClickHandler.bind(this,
EVENT_TYPES.mouseup));
this.area.dblclick(
this._onMouseClickHandler.bind(this, EVENT_TYPES.mousedblclick));
this.area.contextmenu(() => false);
this.area[0].onmousewheel = event => {
this._sendRemoteControlEvent(this.controlledParticipant, {
type: EVENT_TYPES.mousescroll,
x: event.deltaX,
y: event.deltaY
});
};
$(window).keydown(this._onKeyPessHandler.bind(this,
EVENT_TYPES.keydown));
$(window).keyup(this._onKeyPessHandler.bind(this, EVENT_TYPES.keyup));
}
/**
* Stops processing the mouse and keyboard events. Removes added listeners.
* Enables the keyboard shortcuts. Displays dialog to notify the user that
* remote control session has ended.
*/
_stop() {
if(!this.controlledParticipant) {
return;
}
logger.log("Stopping remote control controller.");
APP.UI.removeListener(UIEvents.LARGE_VIDEO_ID_CHANGED,
this._largeVideoChangedListener);
APP.conference.removeConferenceListener(
ConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
this._stopListener);
APP.conference.removeConferenceListener(ConferenceEvents.USER_LEFT,
this._userLeftListener);
this.controlledParticipant = null;
this.pause();
this.area = null;
APP.UI.messageHandler.openMessageDialog(
"dialog.remoteControlTitle",
"dialog.remoteControlStopMessage"
);
}
/**
* Executes this._stop() mehtod:
* Stops processing the mouse and keyboard events. Removes added listeners.
* Enables the keyboard shortcuts. Displays dialog to notify the user that
* remote control session has ended.
*
* In addition:
* Sends stop message to the controlled participant.
*/
stop() {
if(!this.controlledParticipant) {
return;
}
this._sendRemoteControlEvent(this.controlledParticipant, {
type: EVENT_TYPES.stop
});
this._stop();
}
/**
* Pauses the collecting of events and enables the keyboard shortcus. But
* it doesn't removes any other listeners. Basically the remote control
* session will be still active after this.pause(), but no events from the
* controller side will be captured and sent.
*
* You can resume the collecting of the events with this.resume().
*/
pause() {
if(!this.controlledParticipant) {
return;
}
logger.log("Pausing remote control controller.");
this.isCollectingEvents = false;
APP.keyboardshortcut.enable(true);
this.area.off( "mousemove" );
this.area.off( "mousedown" );
this.area.off( "mouseup" );
this.area.off( "contextmenu" );
this.area.off( "dblclick" );
$(window).off( "keydown");
$(window).off( "keyup");
this.area[0].onmousewheel = undefined;
}
/**
* Handler for mouse click events.
* @param {String} type the type of event ("mousedown"/"mouseup")
* @param {Event} event the mouse event.
*/
_onMouseClickHandler(type, event) {
this._sendRemoteControlEvent(this.controlledParticipant, {
type: type,
button: event.which
});
}
/**
* Returns true if the remote control session is started.
* @returns {boolean}
*/
isStarted() {
return this.controlledParticipant !== null;
}
/**
* Returns the id of the requested participant
* @returns {string} this.requestedParticipant.
* NOTE: This id should be the result of JitsiParticipant.getId() call.
*/
getRequestedParticipant() {
return this.requestedParticipant;
}
/**
* Handler for key press events.
* @param {String} type the type of event ("keydown"/"keyup")
* @param {Event} event the key event.
*/
_onKeyPessHandler(type, event) {
this._sendRemoteControlEvent(this.controlledParticipant, {
type: type,
key: getKey(event),
modifiers: getModifiers(event),
});
}
/**
* Calls the stop method if the other side have left.
* @param {string} id - the user id for the participant that have left
*/
_onUserLeft(id) {
if(this.controlledParticipant === id) {
this._stop();
}
}
/**
* Handles changes of the participant displayed on the large video.
* @param {string} id - the user id for the participant that is displayed.
*/
_onLargeVideoIdChanged(id) {
if (!this.controlledParticipant) {
return;
}
if(this.controlledParticipant == id) {
this.resume();
} else {
this.pause();
}
}
}

View File

@ -0,0 +1,192 @@
/* global APP, JitsiMeetJS, interfaceConfig */
const logger = require("jitsi-meet-logger").getLogger(__filename);
import {DISCO_REMOTE_CONTROL_FEATURE, REMOTE_CONTROL_EVENT_TYPE, EVENT_TYPES,
PERMISSIONS_ACTIONS} from "../../service/remotecontrol/Constants";
import RemoteControlParticipant from "./RemoteControlParticipant";
import * as JitsiMeetConferenceEvents from '../../ConferenceEvents';
const ConferenceEvents = JitsiMeetJS.events.conference;
/**
* This class represents the receiver party for a remote controller session.
* It handles "remote-control-event" events and sends them to the
* API module. From there the events can be received from wrapper application
* and executed.
*/
export default class Receiver extends RemoteControlParticipant {
/**
* Creates new instance.
* @constructor
*/
constructor() {
super();
this.controller = null;
this._remoteControlEventsListener
= this._onRemoteControlEvent.bind(this);
this._userLeftListener = this._onUserLeft.bind(this);
this._hangupListener = this._onHangup.bind(this);
}
/**
* Enables / Disables the remote control
* @param {boolean} enabled the new state.
*/
enable(enabled) {
if(this.enabled === enabled) {
return;
}
this.enabled = enabled;
if(enabled === true) {
logger.log("Remote control receiver enabled.");
// Announce remote control support.
APP.connection.addFeature(DISCO_REMOTE_CONTROL_FEATURE, true);
APP.conference.addConferenceListener(
ConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
this._remoteControlEventsListener);
APP.conference.addListener(JitsiMeetConferenceEvents.BEFORE_HANGUP,
this._hangupListener);
} else {
logger.log("Remote control receiver disabled.");
this._stop(true);
APP.connection.removeFeature(DISCO_REMOTE_CONTROL_FEATURE);
APP.conference.removeConferenceListener(
ConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
this._remoteControlEventsListener);
APP.conference.removeListener(
JitsiMeetConferenceEvents.BEFORE_HANGUP,
this._hangupListener);
}
}
/**
* Removes the listener for ConferenceEvents.ENDPOINT_MESSAGE_RECEIVED
* events. Sends stop message to the wrapper application. Optionally
* displays dialog for informing the user that remote control session
* ended.
* @param {boolean} dontShowDialog - if true the dialog won't be displayed.
*/
_stop(dontShowDialog = false) {
if(!this.controller) {
return;
}
logger.log("Remote control receiver stop.");
this.controller = null;
APP.conference.removeConferenceListener(ConferenceEvents.USER_LEFT,
this._userLeftListener);
APP.API.sendRemoteControlEvent({
type: EVENT_TYPES.stop
});
if(!dontShowDialog) {
APP.UI.messageHandler.openMessageDialog(
"dialog.remoteControlTitle",
"dialog.remoteControlStopMessage"
);
}
}
/**
* Calls this._stop() and sends stop message to the controller participant
*/
stop() {
if(!this.controller) {
return;
}
this._sendRemoteControlEvent(this.controller, {
type: EVENT_TYPES.stop
});
this._stop();
}
/**
* Listens for data channel EndpointMessage events. Handles only events of
* type remote control. Sends "remote-control-event" events to the API
* module.
* @param {JitsiParticipant} participant the controller participant
* @param {Object} event EndpointMessage event from the data channels.
* @property {string} type property. The function process only events of
* type REMOTE_CONTROL_EVENT_TYPE
* @property {RemoteControlEvent} event - the remote control event.
*/
_onRemoteControlEvent(participant, event) {
if(this.enabled && event.type === REMOTE_CONTROL_EVENT_TYPE) {
const remoteControlEvent = event.event;
if(this.controller === null
&& remoteControlEvent.type === EVENT_TYPES.permissions
&& remoteControlEvent.action === PERMISSIONS_ACTIONS.request) {
remoteControlEvent.userId = participant.getId();
remoteControlEvent.userJID = participant.getJid();
remoteControlEvent.displayName = participant.getDisplayName()
|| interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME;
remoteControlEvent.screenSharing
= APP.conference.isSharingScreen;
} else if(this.controller !== participant.getId()) {
return;
} else if(remoteControlEvent.type === EVENT_TYPES.stop) {
this._stop();
return;
}
APP.API.sendRemoteControlEvent(remoteControlEvent);
} else if(event.type === REMOTE_CONTROL_EVENT_TYPE) {
logger.log("Remote control event is ignored because remote "
+ "control is disabled", event);
}
}
/**
* Handles remote control permission events received from the API module.
* @param {String} userId the user id of the participant related to the
* event.
* @param {PERMISSIONS_ACTIONS} action the action related to the event.
*/
_onRemoteControlPermissionsEvent(userId, action) {
if(action === PERMISSIONS_ACTIONS.grant) {
APP.conference.addConferenceListener(ConferenceEvents.USER_LEFT,
this._userLeftListener);
this.controller = userId;
logger.log("Remote control permissions granted to: " + userId);
if(!APP.conference.isSharingScreen) {
APP.conference.toggleScreenSharing();
APP.conference.screenSharingPromise.then(() => {
if(APP.conference.isSharingScreen) {
this._sendRemoteControlEvent(userId, {
type: EVENT_TYPES.permissions,
action: action
});
} else {
this._sendRemoteControlEvent(userId, {
type: EVENT_TYPES.permissions,
action: PERMISSIONS_ACTIONS.error
});
}
}).catch(() => {
this._sendRemoteControlEvent(userId, {
type: EVENT_TYPES.permissions,
action: PERMISSIONS_ACTIONS.error
});
});
return;
}
}
this._sendRemoteControlEvent(userId, {
type: EVENT_TYPES.permissions,
action: action
});
}
/**
* Calls the stop method if the other side have left.
* @param {string} id - the user id for the participant that have left
*/
_onUserLeft(id) {
if(this.controller === id) {
this._stop();
}
}
/**
* Handles hangup events. Disables the receiver.
*/
_onHangup() {
this.enable(false);
}
}

View File

@ -0,0 +1,89 @@
/* global APP, config */
const logger = require("jitsi-meet-logger").getLogger(__filename);
import Controller from "./Controller";
import Receiver from "./Receiver";
import {EVENT_TYPES, DISCO_REMOTE_CONTROL_FEATURE}
from "../../service/remotecontrol/Constants";
/**
* Implements the remote control functionality.
*/
class RemoteControl {
/**
* Constructs new instance. Creates controller and receiver properties.
* @constructor
*/
constructor() {
this.controller = new Controller();
this.receiver = new Receiver();
this.enabled = false;
this.initialized = false;
}
/**
* Initializes the remote control - checks if the remote control should be
* enabled or not, initializes the API module.
*/
init() {
if(config.disableRemoteControl || this.initialized
|| !APP.conference.isDesktopSharingEnabled) {
return;
}
logger.log("Initializing remote control.");
this.initialized = true;
APP.API.init({
forceEnable: true,
});
this.controller.enable(true);
if(this.enabled) { // supported message came before init.
this._onRemoteControlSupported();
}
}
/**
* Handles remote control events from the API module. Currently only events
* with type = EVENT_TYPES.supported or EVENT_TYPES.permissions
* @param {RemoteControlEvent} event the remote control event.
*/
onRemoteControlAPIEvent(event) {
switch(event.type) {
case EVENT_TYPES.supported:
this._onRemoteControlSupported();
break;
case EVENT_TYPES.permissions:
this.receiver._onRemoteControlPermissionsEvent(
event.userId, event.action);
break;
}
}
/**
* Handles API event for support for executing remote control events into
* the wrapper application.
*/
_onRemoteControlSupported() {
logger.log("Remote Control supported.");
if(!config.disableRemoteControl) {
this.enabled = true;
if(this.initialized) {
this.receiver.enable(true);
}
} else {
logger.log("Remote Control disabled.");
}
}
/**
* Checks whether the passed user supports remote control or not
* @param {JitsiParticipant} user the user to be tested
* @returns {Promise<boolean>} the promise will be resolved with true if
* the user supports remote control and with false if not.
*/
checkUserRemoteControlSupport(user) {
return user.getFeatures().then(features =>
features.has(DISCO_REMOTE_CONTROL_FEATURE), () => false
);
}
}
export default new RemoteControl();

View File

@ -0,0 +1,42 @@
/* global APP */
const logger = require("jitsi-meet-logger").getLogger(__filename);
import {REMOTE_CONTROL_EVENT_TYPE}
from "../../service/remotecontrol/Constants";
export default class RemoteControlParticipant {
/**
* Creates new instance.
*/
constructor() {
this.enabled = false;
}
/**
* Enables / Disables the remote control
* @param {boolean} enabled the new state.
*/
enable(enabled) {
this.enabled = enabled;
}
/**
* Sends remote control event to other participant trough data channel.
* @param {RemoteControlEvent} event the remote control event.
* @param {Function} onDataChannelFail handler for data channel failure.
*/
_sendRemoteControlEvent(to, event, onDataChannelFail = () => {}) {
if(!this.enabled || !to) {
logger.warn("Remote control: Skip sending remote control event."
+ " Params:", this.enable, to);
return;
}
try{
APP.conference.sendEndpointMessage(to,
{type: REMOTE_CONTROL_EVENT_TYPE, event});
} catch (e) {
logger.error("Failed to send EndpointMessage via the datachannels",
e);
onDataChannelFail(e);
}
}
}

View File

@ -73,10 +73,6 @@ class TokenData{
this.jwt = jwt;
//External API settings
this.externalAPISettings = {
forceEnable: true
};
this._decode();
// Use JWT param as token if there is not other token set and if the
// iss field is not anonymous. If you want to pass data with JWT token

View File

@ -23,7 +23,11 @@ export function init() {
APP.keyboardshortcut = KeyboardShortcut;
APP.tokenData = getTokenData();
APP.API.init(APP.tokenData.externalAPISettings);
// Force enable the API if jwt token is passed because most probably
// jitsi meet is displayed inside of wrapper that will need to communicate
// with jitsi meet.
APP.API.init(APP.tokenData.jwt ? { forceEnable: true } : undefined);
APP.translation.init(settings.getLanguage());
}

View File

@ -120,6 +120,11 @@ export default {
*/
LARGE_VIDEO_AVATAR_VISIBLE: "UI.large_video_avatar_visible",
/**
* Notifies that the displayed particpant id on the largeVideo is changed.
*/
LARGE_VIDEO_ID_CHANGED: "UI.large_video_id_changed",
/**
* Toggling room lock
*/

View File

@ -0,0 +1,69 @@
/**
* The value for the "var" attribute of feature tag in disco-info packets.
*/
export const DISCO_REMOTE_CONTROL_FEATURE
= "http://jitsi.org/meet/remotecontrol";
/**
* Types of remote-control-event events.
* @readonly
* @enum {string}
*/
export const EVENT_TYPES = {
mousemove: "mousemove",
mousedown: "mousedown",
mouseup: "mouseup",
mousedblclick: "mousedblclick",
mousescroll: "mousescroll",
keydown: "keydown",
keyup: "keyup",
permissions: "permissions",
stop: "stop",
supported: "supported"
};
/**
* Actions for the remote control permission events.
* @readonly
* @enum {string}
*/
export const PERMISSIONS_ACTIONS = {
request: "request",
grant: "grant",
deny: "deny",
error: "error"
};
/**
* The type of remote control events sent trough the API module.
*/
export const REMOTE_CONTROL_EVENT_TYPE = "remote-control-event";
/**
* The remote control event.
* @typedef {object} RemoteControlEvent
* @property {EVENT_TYPES} type - the type of the event
* @property {int} x - avaibale for type === mousemove only. The new x
* coordinate of the mouse
* @property {int} y - For mousemove type - the new y
* coordinate of the mouse and for mousescroll - represents the vertical
* scrolling diff value
* @property {int} button - 1(left), 2(middle) or 3 (right). Supported by
* mousedown, mouseup and mousedblclick types.
* @property {KEYS} key - Represents the key related to the event. Supported by
* keydown and keyup types.
* @property {KEYS[]} modifiers - Represents the modifier related to the event.
* Supported by keydown and keyup types.
* @property {PERMISSIONS_ACTIONS} action - Supported by type === permissions.
* Represents the action related to the permissions event.
*
* Optional properties. Supported for permissions event for action === request:
* @property {string} userId - The user id of the participant that has sent the
* request.
* @property {string} userJID - The full JID in the MUC of the user that has
* sent the request.
* @property {string} displayName - the displayName of the participant that has
* sent the request.
* @property {boolean} screenSharing - true if the SS is started for the local
* participant and false if not.
*/