diff --git a/demo/index.html b/demo/index.html index 43415cf0..3d6d4981 100644 --- a/demo/index.html +++ b/demo/index.html @@ -67,7 +67,7 @@

Get Media Sources:

- +
@@ -193,14 +193,14 @@ var getSources = document.getElementById('get-sources'); getSources.addEventListener('click', function() { - ssf.getMediaSources({types: ['window', 'screen']}, function(error, sources) { + ssf.getMediaSource({types: ['window', 'screen']}, function(error, source) { if (error) throw error navigator.webkitGetUserMedia({ audio: false, video: { mandatory: { chromeMediaSource: 'desktop', - chromeMediaSourceId: sources[0].id, + chromeMediaSourceId: source.id, minWidth: 1280, maxWidth: 1280, minHeight: 720, diff --git a/js/desktopCapturer/getSource.js b/js/desktopCapturer/getSource.js new file mode 100644 index 00000000..f96cd73b --- /dev/null +++ b/js/desktopCapturer/getSource.js @@ -0,0 +1,96 @@ +'use strict'; + +// This code provides equivalent of desktopCapturer.getSources that works in +// a sandbox renderer. see: https://electron.atom.io/docs/api/desktop-capturer/ +// +// The code here is not entirely kosher/stable as it is using private ipc +// events. The code was take directly from electron.asar file provided in +// prebuilt node module. Note: the slight difference here is the thumbnail +// returns a base64 encoded image rather than a electron nativeImage. +// +// Until electron provides access to desktopCapturer in a sandboxed +// renderer process, this will have to do. See github issue posted here to +// electron: https://github.com/electron/electron/issues/9312 + +const { ipcRenderer, remote } = require('electron'); +const apiEnums = require('../enums/api.js'); +const apiCmds = apiEnums.cmds; +const apiName = apiEnums.apiName; +const { isWindowsOS } = require('../utils/misc'); +const USER_CANCELLED = 'User Cancelled'; + +let nextId = 0; +let includes = [].includes; + +function getNextId() { + return ++nextId; +} + +/** + * Checks if the options and their types are valid + * @param options |options.type| can not be empty and has to include 'window' or 'screen'. + * @returns {boolean} + */ +function isValid(options) { + return ((options !== null ? options.types : undefined) !== null) && Array.isArray(options.types); +} + +/** + * Gets the sources for capturing screens / windows + * @param options + * @param callback + * @returns {*} + */ +function getSource(options, callback) { + let captureScreen, captureWindow, id; + if (!isValid(options)) { + return callback(new Error('Invalid options')); + } + captureWindow = includes.call(options.types, 'window'); + captureScreen = includes.call(options.types, 'screen'); + + let updatedOptions = options; + if (!updatedOptions.thumbnailSize) { + updatedOptions.thumbnailSize = { + width: 150, + height: 150 + }; + } + + if (isWindowsOS) { + /** + * Sets the captureWindow to false if Desktop composition + * is disabled otherwise true + * + * Setting captureWindow to false returns only screen sources + * @type {boolean} + */ + captureWindow = remote.systemPreferences.isAeroGlassEnabled(); + } + + id = getNextId(); + ipcRenderer.send('ELECTRON_BROWSER_DESKTOP_CAPTURER_GET_SOURCES', captureWindow, captureScreen, updatedOptions.thumbnailSize, id); + + return ipcRenderer.once('ELECTRON_RENDERER_DESKTOP_CAPTURER_RESULT_' + id, function(event, sources) { + + ipcRenderer.send(apiName, { + cmd: apiCmds.openScreenPickerWindow, + sources: sources, + id: id + }); + + function successCallback(e, source) { + // Cleaning up the event listener to prevent memory leaks + if (!source) { + ipcRenderer.removeListener('start-share' + id, func); + return callback(new Error(USER_CANCELLED)); + } + return callback(null, source); + } + + const func = successCallback.bind(this); + ipcRenderer.once('start-share' + id, func); + }); +} + +module.exports = getSource; \ No newline at end of file diff --git a/js/desktopCapturer/getSources.js b/js/desktopCapturer/getSources.js index fbce373d..4e2ca10b 100644 --- a/js/desktopCapturer/getSources.js +++ b/js/desktopCapturer/getSources.js @@ -87,4 +87,8 @@ function getSources(options, callback) { }); } +/** + * @deprecated instead use getSource + * @type {getSources} + */ module.exports = getSources; \ No newline at end of file diff --git a/js/desktopCapturer/index.js b/js/desktopCapturer/index.js new file mode 100644 index 00000000..4d238c2c --- /dev/null +++ b/js/desktopCapturer/index.js @@ -0,0 +1,140 @@ +'use strict'; + +const electron = require('electron'); +const BrowserWindow = electron.BrowserWindow; +const ipc = electron.ipcMain; +const path = require('path'); +const fs = require('fs'); +const log = require('../log.js'); +const logLevels = require('../enums/logLevels.js'); +const { isMac, isWindowsOS } = require('./../utils/misc.js'); + +let screenPickerWindow; +let preloadWindow; +let eventId; + +let windowConfig = { + width: 580, + height: isMac ? 519 : 523, + show: false, + modal: true, + frame: false, + autoHideMenuBar: true, + resizable: false, + alwaysOnTop: true, + webPreferences: { + preload: path.join(__dirname, 'renderer.js'), + sandbox: true, + nodeIntegration: false + } +}; + +/** + * method to get the HTML template path + * @returns {string} + */ +function getTemplatePath() { + let templatePath = path.join(__dirname, 'screen-picker.html'); + try { + fs.statSync(templatePath).isFile(); + } catch (err) { + log.send(logLevels.ERROR, 'screen-picker: Could not find template ("' + templatePath + '").'); + } + return 'file://' + templatePath; +} + +/** + * Creates the screen picker window + * @param eventSender {RTCRtpSender} - event sender window object + * @param sources {Array} - list of object which has screens and applications + * @param id {Number} - event emitter id + */ +function openScreenPickerWindow(eventSender, sources, id) { + + // prevent a new window from being opened if there is an + // existing window / there is no event sender + if (!eventSender || screenPickerWindow) { + return; + } + + // Screen picker will always be placed on top of the focused window + const focusedWindow = BrowserWindow.getFocusedWindow(); + + // As screen picker is an independent window this will make sure + // it will open screen picker window center of the focused window + if (focusedWindow) { + const { x, y, width, height } = focusedWindow.getBounds(); + + if (x !== undefined && y !== undefined) { + const windowWidth = Math.round(width * 0.5); + const windowHeight = Math.round(height * 0.5); + + // Calculating the center of the parent window + // to place the configuration window + const centerX = x + width / 2.0; + const centerY = y + height / 2.0; + windowConfig.x = Math.round(centerX - (windowWidth / 2.0)); + windowConfig.y = Math.round(centerY - (windowHeight / 2.0)); + } + } + + // Store the window ref to send event + preloadWindow = eventSender; + eventId = id; + + screenPickerWindow = new BrowserWindow(windowConfig); + screenPickerWindow.setVisibleOnAllWorkspaces(true); + screenPickerWindow.loadURL(getTemplatePath()); + + screenPickerWindow.once('ready-to-show', () => { + screenPickerWindow.show(); + }); + + screenPickerWindow.webContents.on('did-finish-load', () => { + screenPickerWindow.webContents.send('desktop-capturer-sources', sources, isWindowsOS); + }); + + screenPickerWindow.on('close', () => { + destroyWindow(); + }); + + screenPickerWindow.on('closed', () => { + destroyWindow(); + }); + +} + +/** + * Destroys a window + */ +function destroyWindow() { + // sending null will clean up the event listener + startScreenShare(null); + screenPickerWindow = null; +} + +/** + * Sends an event to a specific with the selected source + * @param source {Object} - User selected source + */ +function startScreenShare(source) { + if (preloadWindow && !preloadWindow.isDestroyed()) { + preloadWindow.send('start-share' + eventId, source); + } +} + +// Emitted when user has selected a source and press the share button +ipc.on('share-selected-source', (event, source) => { + startScreenShare(source); +}); + +// Emitted when user closes the screen picker window +ipc.on('close-screen-picker', () => { + if (screenPickerWindow && !screenPickerWindow.isDestroyed()) { + screenPickerWindow.close(); + } +}); + +module.exports = { + openScreenPickerWindow +}; \ No newline at end of file diff --git a/js/desktopCapturer/renderer.js b/js/desktopCapturer/renderer.js new file mode 100644 index 00000000..06741365 --- /dev/null +++ b/js/desktopCapturer/renderer.js @@ -0,0 +1,268 @@ +'use strict'; +const { ipcRenderer } = require('electron'); + +const screenRegExp = new RegExp(/^Screen \d+$/gmi); + +// All the required Keyboard keyCode events +const keyCodeEnum = Object.freeze({ + pageDown: 34, + rightArrow: 39, + pageUp: 33, + leftArrow: 37, + homeKey: 36, + upArrow: 38, + endKey: 35, + arrowDown: 40, + enterKey: 13, + escapeKey: 27 +}); + +let availableSources; +let selectedSource; +let currentIndex = -1; + +document.addEventListener('DOMContentLoaded', () => { + renderDom(); +}); + +/** + * Method that renders application data + */ +function renderDom() { + const applicationTab = document.getElementById('application-tab'); + const screenTab = document.getElementById('screen-tab'); + const share = document.getElementById('share'); + const cancel = document.getElementById('cancel'); + const xButton = document.getElementById('x-button'); + + // Event listeners + xButton.addEventListener('click', () => { + closeScreenPickerWindow(); + }, false); + + share.addEventListener('click', () => { + startShare(); + }, false); + + cancel.addEventListener('click', () => { + closeScreenPickerWindow(); + }, false); + + screenTab.addEventListener('click', () => { + updateShareButtonText('Select Screen'); + }, false); + + applicationTab.addEventListener('click', () => { + updateShareButtonText('Select Application'); + }, false); + + document.addEventListener('keyup', handleKeyUpPress.bind(this), true); + +} + +/** + * Event emitted by main process with a list of available + * Screens and Applications + */ +ipcRenderer.on('desktop-capturer-sources', (event, sources, isWindowsOS) => { + + if (!Array.isArray(sources) && typeof isWindowsOS !== 'boolean') { + return; + } + availableSources = sources; + + if (isWindowsOS) { + document.body.classList.add('window-border'); + } + + const screenContent = document.getElementById('screen-contents'); + const applicationContent = document.getElementById('application-contents'); + const applicationTab = document.getElementById('applications'); + const screenTab = document.getElementById('screens'); + + let hasScreens = false; + let hasApplications = false; + + for (let source of sources) { + screenRegExp.lastIndex = 0; + if (source.name === 'Entire screen' || screenRegExp.exec(source.name)) { + source.fileName = 'fullscreen'; + screenContent.appendChild(createItem(source)); + hasScreens = true; + } else { + source.fileName = null; + applicationContent.appendChild(createItem(source)); + hasApplications = true; + } + } + + if (!hasScreens && !hasApplications) { + const errorContent = document.getElementById('error-content'); + const mainContent = document.getElementById('main-content'); + + errorContent.style.display = 'block'; + mainContent.style.display = 'none'; + } + + if (hasApplications) { + applicationTab.classList.remove('hidden'); + } + + if (hasScreens) { + screenTab.classList.remove('hidden'); + } +}); + +function startShare() { + if (selectedSource && selectedSource.id) { + ipcRenderer.send('share-selected-source', selectedSource); + closeScreenPickerWindow(); + } +} + +/** + * Creates DOM elements and injects data + * @param source + * @returns {HTMLDivElement} + */ +function createItem(source) { + const itemContainer = document.createElement("div"); + const sectionBox = document.createElement("div"); + const imageTag = document.createElement("img"); + const titleContainer = document.createElement("div"); + + // Added class names to the dom elements + itemContainer.classList.add('item-container'); + sectionBox.classList.add('screen-section-box'); + imageTag.classList.add('img-wrapper'); + titleContainer.classList.add('screen-source-title'); + + // Inject data to the dom element + imageTag.src = source.thumbnail; + titleContainer.innerText = source.name; + + sectionBox.appendChild(imageTag); + itemContainer.id = source.id; + itemContainer.appendChild(sectionBox); + itemContainer.appendChild(titleContainer); + + itemContainer.addEventListener('click', updateUI.bind(this, source, itemContainer), false); + + return itemContainer; +} + +/** + * When ever user select a source store it and update the UI + * @param source + * @param itemContainer + */ +function updateUI(source, itemContainer) { + selectedSource = source; + + let shareButton = document.getElementById('share'); + shareButton.className = 'share-button'; + + highlightSelectedSource(); + itemContainer.classList.add('selected'); + shareButton.innerText = 'Share' +} + +/** + * Loops through the items and removes + * the selected class property + */ +function highlightSelectedSource() { + let items = document.getElementsByClassName('item-container'); + for (const item of items) { + item.classList.remove('selected'); + } +} + +/** + * Method that updates the share button + * text based on the content type + * @param text + */ +function updateShareButtonText(text) { + let shareButton = document.getElementById('share'); + + if (shareButton && shareButton.classList[0] === 'share-button-disable') { + shareButton.innerText = text; + } +} + +/** + * Method handles used key up event + * @param e + */ +function handleKeyUpPress(e) { + const keyCode = e.keyCode || e.which; + + switch (keyCode) { + case keyCodeEnum.pageDown: + case keyCodeEnum.rightArrow: + updateSelectedSource(1); + break; + case keyCodeEnum.pageUp: + case keyCodeEnum.leftArrow: + updateSelectedSource(-1); + break; + case keyCodeEnum.homeKey: + if (currentIndex !== 0) { + updateSelectedSource(0); + } + break; + case keyCodeEnum.upArrow: + updateSelectedSource(-2); + break; + case keyCodeEnum.endKey: + if (currentIndex !== availableSources.length - 1) { + updateSelectedSource(availableSources.length - 1); + } + break; + case keyCodeEnum.arrowDown: + updateSelectedSource(2); + break; + case keyCodeEnum.enterKey: + startShare(); + break; + case keyCodeEnum.escapeKey: + closeScreenPickerWindow(); + break; + default: + break; + } +} + +/** + * Updates UI based on the key press + * @param index + */ +function updateSelectedSource(index) { + + let selectedElement = document.getElementsByClassName('selected')[0]; + if (selectedElement) { + currentIndex = availableSources.findIndex((source) => { + return source.id === selectedElement.id + }); + } + + // Find the next item to be selected + let nextIndex = (currentIndex + index + availableSources.length) % availableSources.length; + if (availableSources[nextIndex] && availableSources[nextIndex].id) { + let item = document.getElementById(availableSources[nextIndex] ? availableSources[nextIndex].id : ""); + + if (item) { + // Method that stores and update the selected source + updateUI(availableSources[nextIndex], item); + } + } +} + +/** + * Closes the screen picker window + */ +function closeScreenPickerWindow() { + document.removeEventListener('keyUp', handleKeyUpPress.bind(this), true); + ipcRenderer.send('close-screen-picker'); +} \ No newline at end of file diff --git a/js/desktopCapturer/screen-picker.html b/js/desktopCapturer/screen-picker.html new file mode 100644 index 00000000..63aab5b7 --- /dev/null +++ b/js/desktopCapturer/screen-picker.html @@ -0,0 +1,244 @@ + + + + + Screen Picker + + + +
+
+ Choose what you'd like to share +
+
+ + + + + +
+
+
+
+ No screens or applications are currently available. +
+
+ + + + +
+
+
+
+
+ + +
+ + + \ No newline at end of file diff --git a/js/enums/api.js b/js/enums/api.js index c6941e43..16fda17d 100644 --- a/js/enums/api.js +++ b/js/enums/api.js @@ -17,7 +17,8 @@ const cmds = keyMirror({ registerActivityDetection: null, showNotificationSettings: null, sanitize: null, - bringToFront: null + bringToFront: null, + openScreenPickerWindow: null }); module.exports = { diff --git a/js/mainApiMgr.js b/js/mainApiMgr.js index 98bfbf32..f7349a9c 100644 --- a/js/mainApiMgr.js +++ b/js/mainApiMgr.js @@ -16,6 +16,7 @@ const configureNotification = require('./notify/settings/configure-notification- const { bringToFront } = require('./bringToFront.js'); const eventEmitter = require('./eventEmitter'); const { isMac } = require('./utils/misc'); +const { openScreenPickerWindow } = require('./desktopCapturer'); const apiEnums = require('./enums/api.js'); const apiCmds = apiEnums.cmds; @@ -135,6 +136,11 @@ electron.ipcMain.on(apiName, (event, arg) => { bringToFront(arg.windowName, arg.reason); } break; + case apiCmds.openScreenPickerWindow: + if (Array.isArray(arg.sources) && typeof arg.id === 'number') { + openScreenPickerWindow(event.sender, arg.sources, arg.id); + } + break; default: } diff --git a/js/preload/preloadMain.js b/js/preload/preloadMain.js index 84700a9d..8beba256 100644 --- a/js/preload/preloadMain.js +++ b/js/preload/preloadMain.js @@ -18,6 +18,7 @@ const apiEnums = require('../enums/api.js'); const apiCmds = apiEnums.cmds; const apiName = apiEnums.apiName; const getMediaSources = require('../desktopCapturer/getSources'); +const getMediaSource = require('../desktopCapturer/getSource'); require('../downloadManager'); @@ -260,9 +261,22 @@ function createAPI() { * a sandboxed renderer process. * see: https://electron.atom.io/docs/api/desktop-capturer/ * for interface: see documentation in desktopCapturer/getSources.js + * + * @deprecated instead use getMediaSource */ getMediaSources: getMediaSources, + /** + * Implements equivalent of desktopCapturer.getSources - that works in + * a sandboxed renderer process. + * see: https://electron.atom.io/docs/api/desktop-capturer/ + * for interface: see documentation in desktopCapturer/getSource.js + * + * This opens a window and displays all the desktop sources + * and returns selected source + */ + getMediaSource: getMediaSource, + /** * Opens a modal window to configure notification preference. */