UNPKG

@jitsi/electron-sdk

Version:
339 lines (292 loc) 11.2 kB
/* global __dirname */ const { EventEmitter } = require('events'); const { ipcRenderer } = require('electron'); const path = require('path'); const { logInfo, setLogger } = require('./utils'); const { EVENTS, STATES, AOT_WINDOW_NAME, EXTERNAL_EVENTS, EVENTS_CHANNEL } = require('../constants'); /** * Sends an update state event to main process * @param {string} state the updated aot window state */ const sendStateUpdate = state => { logInfo(`sending ${state} state update to main process`); ipcRenderer.send(EVENTS_CHANNEL, { name: EVENTS.UPDATE_STATE, state } ); }; /** * Sends a move command to the main process * @param {Number} x * @param {Number} y * @param {Object} initialSize - The size of the window on move start. */ const move = (x, y, initialSize) => { ipcRenderer.send(EVENTS_CHANNEL, { name: EVENTS.MOVE, initialSize, position: { x, y }, }); }; class AlwaysOnTop extends EventEmitter { /** * Creates new instance. * * @param {JitsiIFrameApi} api - the Jitsi Meet iframe api object. * @param {Object} options - AOT options. */ constructor(api, { showOnPrejoin }) { super(); this._api = api; this._showOnPrejoin = showOnPrejoin; this._joined = false; this._disposeWindow = this._disposeWindow.bind(this); this._dismiss = this._dismiss.bind(this); this._onConferenceJoined = this._onConferenceJoined.bind(this); this._onAotEvent = this._onAotEvent.bind(this); this._handleStateChange = this._handleStateChange.bind(this); this._switchToMainWindow = this._switchToMainWindow.bind(this); this._updateLargeVideoSrc = this._updateLargeVideoSrc.bind(this); this._onIntersection = this._onIntersection.bind(this); this._intersectionObserver = new IntersectionObserver(this._onIntersection); this._api.on('videoConferenceJoined', this._onConferenceJoined); this._api.on('readyToClose', this._disposeWindow); this._api.on('_willDispose', this._disposeWindow); if (showOnPrejoin) { this._api.on('prejoinScreenLoaded', this._onConferenceJoined); } window.parent.addEventListener('beforeunload', this._disposeWindow); } /** * Getter for the large/prejoin video element in Jitsi Meet. * * @returns {HTMLElement|undefined} the large video. */ get _jitsiMeetLargeVideo() { if (this._showOnPrejoin) { return this._api._getPrejoinVideo() || this._api._getLargeVideo(); } else { return this._api._getLargeVideo(); } } /** * Getter for the target video element in the always on top window * * @returns {HTMLElement|undefined} the large video. */ get _aotWindowVideo() { if (!this._aotWindow || !this._aotWindow.document) { return undefined; } return this._aotWindow.document.getElementById('video'); } _onConferenceJoined() { logInfo('on conference joined'); if (this._joined) { logInfo('conference already joined'); return; } this._joined = true; ipcRenderer.on(EVENTS_CHANNEL, this._onAotEvent); sendStateUpdate(STATES.CONFERENCE_JOINED); this._intersectionObserver.observe(this._api.getIFrame()); } /** * Handles intersection events for the instance's IntersectionObserver * * @param {IntersectionObserverEntry[]} entries * @param {IntersectionObserver} observer */ _onIntersection(entries) { logInfo('handling main window intersection'); const singleEntry = entries.pop(); if (singleEntry.isIntersecting) { sendStateUpdate(STATES.IS_INTERSECTING); } else { sendStateUpdate(STATES.IS_NOT_INTERSECTING); } } /** * Updates the source of the always on top window when the source of the * large video is changed. * * @returns {void} */ _updateLargeVideoSrc() { // adding a small timeout before updating media seems to fix the black screen preview // in the bottom right corner during screen share. setTimeout(() => { if (!this._aotWindow) { return; } if (!this._jitsiMeetLargeVideo) { this._aotWindowVideo.style.display = 'none'; this._aotWindowVideo.srcObject = null; } else { this._aotWindowVideo.style.display = 'block'; const mediaStream = this._jitsiMeetLargeVideo.srcObject; const transform = this._jitsiMeetLargeVideo.style.transform; this._aotWindowVideo.srcObject = mediaStream; this._aotWindowVideo.style.transform = transform; this._aotWindowVideo.play(); } }, 100); } /** * Closes the aot window without causing main process to clear its main window handlers * This allows the aot window to be reopened on another focus - blur sequence on the main window */ _dismiss() { this.emit(EXTERNAL_EVENTS.ALWAYSONTOP_DISMISSED); sendStateUpdate(STATES.DISMISS); } /** * Switches focus to the main window */ _switchToMainWindow() { this.emit(EXTERNAL_EVENTS.ALWAYSONTOP_DOUBLE_CLICK); sendStateUpdate(STATES.SHOW_MAIN_WINDOW); } /** * Opens a new window */ _openNewWindow(magic) { logInfo('new window'); this._api.on('largeVideoChanged', this._updateLargeVideoSrc); this._api.on('prejoinVideoChanged', this._updateLargeVideoSrc); this._api.on('videoMuteStatusChanged', this._updateLargeVideoSrc); this._aotWindow = window.open('', `${AOT_WINDOW_NAME}-${magic}`); this._aotWindow.alwaysOnTop = { api: this._api, dismiss: this._dismiss, /** * Custom implementation for window move. * We use setBounds in order to preserve the initial size of the window * during drag. This is in order to fix: * https://github.com/electron/electron/issues/9477 * @param x * @param y */ move, ondblclick: this._switchToMainWindow, onload: this._updateLargeVideoSrc }; const cssPath = path.join(__dirname, './alwaysontop.css'); const jsPath = path.join(__dirname, './alwaysontop.js'); // Add the markup for the JS to manipulate and load the CSS. this._aotWindow.document.body.innerHTML = ` <div id="react"></div> <video autoplay="" id="video" style="transform: none;" muted></video> <div class="dismiss"></div> <link rel="stylesheet" href="file://${cssPath}"> `; // JS must be loaded through a script tag, as setting it through // inner HTML maybe not trigger script load. const scriptTag = this._aotWindow.document.createElement('script'); scriptTag.setAttribute('src', `file://${jsPath}`); this._aotWindow.document.head.appendChild(scriptTag); } /** * Hides the aot window */ _hideWindow() { this._api.removeListener('largeVideoChanged', this._updateLargeVideoSrc); this._api.removeListener('videoMuteStatusChanged', this._updateLargeVideoSrc); this.emit(EXTERNAL_EVENTS.ALWAYSONTOP_WILL_CLOSE); if (this._aotWindowVideo) { this._aotWindowVideo.srcObject = null; } } /** * Shows the aot window */ _showWindow() { this._api.on('largeVideoChanged', this._updateLargeVideoSrc); this._api.on('prejoinVideoChanged', this._updateLargeVideoSrc); this._api.on('videoMuteStatusChanged', this._updateLargeVideoSrc); this._updateLargeVideoSrc(); } /** * Disposes the aot window * */ _disposeWindow() { logInfo('disposing window'); this._joined = false; this._api.removeListener('largeVideoChanged', this._updateLargeVideoSrc); this._api.removeListener('prejoinVideoChanged', this._updateLargeVideoSrc); this._api.removeListener('videoMuteStatusChanged', this._updateLargeVideoSrc); this._api.removeListener('videoConferenceJoined', this._onConferenceJoined); this._api.removeListener('readyToClose', this._disposeWindow); window.parent.removeEventListener('beforeunload', this._disposeWindow); if (this._showOnPrejoin) { this._api.removeListener('prejoinScreenLoaded', this._onConferenceJoined); } const iframe = this._api.getIFrame(); if (iframe) { this._intersectionObserver.unobserve(this._api.getIFrame()); } this.emit(EXTERNAL_EVENTS.ALWAYSONTOP_WILL_CLOSE); sendStateUpdate(STATES.CLOSE); ipcRenderer.removeListener(EVENTS_CHANNEL, this._onAotEvent); if (this._aotWindow) { delete this._aotWindow.alwaysOnTop; this._aotWindow.close(); this._aotWindow = null; } } /** * Handler for aot channel events * * @param {IpcRendererEvent} event electron event * @param {Object} options channel params */ _onAotEvent (event, { name, ...rest }) { switch (name) { case EVENTS.UPDATE_STATE: this._handleStateChange(rest.state, rest.data); break; } } /** * Handler for state updates * * @param {string} state updated state * @param {Object} data ancillary data to the event */ _handleStateChange (state, data) { logInfo(`handling ${state} state update from main process`); switch (state) { case STATES.HIDE: this._hideWindow(); break; case STATES.OPEN: try { this._openNewWindow(data.aotMagic); } catch(e) { this._api.removeListener('largeVideoChanged', this._updateLargeVideoSrc); this._api.removeListener('prejoinVideoChanged', this._updateLargeVideoSrc); this._api.removeListener('videoMuteStatusChanged', this._updateLargeVideoSrc); } break; case STATES.SHOW: this._showWindow(); break; default: break; } } } /** * Initializes the always on top functionality in the render process of the * window which displays Jitsi Meet. * * @param {JitsiIFrameApi} api - the Jitsi Meet iframe api object. * @param {Logger} loggerTransports - external loggers. * @param {Object} options - AOT options. */ module.exports = (api, loggerTransports, { showOnPrejoin = false } = {}) => { setLogger(loggerTransports); return new AlwaysOnTop(api, { showOnPrejoin: showOnPrejoin && typeof api._getPrejoinVideo === 'function' }); };