/** * The Camera UI View that displays all the camera feeds as individual video elements. * @type {Application} * * @param {WebRTC} webrtc The WebRTC Implementation to display * @param {ApplicationOptions} [options] Application configuration options. */ class CameraViews extends Application { constructor(options={}) { if ( !("width" in options) ) options.width = game.webrtc?.settings.client.dockWidth || 240; super(options); if ( game.webrtc?.settings.client.dockPosition === AVSettings.DOCK_POSITIONS.RIGHT ) { this.options.resizable.rtl = true; } } /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "camera-views", template: "templates/hud/camera-views.html", popOut: false, width: 240, resizable: {selector: ".camera-view-width-control", resizeY: false} }); } /* -------------------------------------------- */ /** * A reference to the master AV orchestrator instance * @type {AVMaster} */ get webrtc() { return game.webrtc; } /* -------------------------------------------- */ /** * If all camera views are popped out, hide the dock. * @type {boolean} */ get hidden() { return this.webrtc.client.getConnectedUsers().reduce((hidden, u) => { const settings = this.webrtc.settings.users[u]; return hidden && (settings.blocked || settings.popout); }, true); } /* -------------------------------------------- */ /* Public API */ /* -------------------------------------------- */ /** * Obtain a reference to the div.camera-view which is used to portray a given Foundry User. * @param {string} userId The ID of the User document * @return {HTMLElement|null} */ getUserCameraView(userId) { return this.element.find(`.camera-view[data-user=${userId}]`)[0] || null; } /* -------------------------------------------- */ /** * Obtain a reference to the video.user-camera which displays the video channel for a requested Foundry User. * If the user is not broadcasting video this will return null. * @param {string} userId The ID of the User document * @return {HTMLVideoElement|null} */ getUserVideoElement(userId) { return this.element.find(`.camera-view[data-user=${userId}] video.user-camera`)[0] || null; } /* -------------------------------------------- */ /** * Sets whether a user is currently speaking or not * * @param {string} userId The ID of the user * @param {boolean} speaking Whether the user is speaking */ setUserIsSpeaking(userId, speaking) { const view = this.getUserCameraView(userId); if ( view ) view.classList.toggle("speaking", speaking); } /* -------------------------------------------- */ /* Application Rendering */ /* -------------------------------------------- */ /** * Extend the render logic to first check whether a render is necessary based on the context * If a specific context was provided, make sure an update to the navigation is necessary before rendering */ render(force, context={}) { const { renderContext, renderData } = context; if ( this.webrtc.mode === AVSettings.AV_MODES.DISABLED ) return this; if ( renderContext ) { if ( renderContext !== "updateUser" ) return this; const updateKeys = ["name", "permissions", "role", "active", "color", "sort", "character", "avatar"]; if ( !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return this; } return super.render(force, context); } /* -------------------------------------------- */ /** @override */ async _render(force = false, options = {}) { await super._render(force, options); this.webrtc.onRender(); } /* -------------------------------------------- */ /** @inheritdoc */ setPosition({left, top, width, scale} = {}) { const position = super.setPosition({left, top, width, height: "auto", scale}); if ( foundry.utils.isEmpty(position) ) return position; const clientSettings = game.webrtc.settings.client; if ( game.webrtc.settings.verticalDock ) { clientSettings.dockWidth = width; game.webrtc.settings.set("client", "dockWidth", width); } return position; } /* -------------------------------------------- */ /** @override */ getData(options={}) { const settings = this.webrtc.settings; const userSettings = settings.users; // Get the sorted array of connected users const connectedIds = this.webrtc.client.getConnectedUsers(); const users = connectedIds.reduce((users, u) => { const data = this._getDataForUser(u, userSettings[u]); if ( data && !userSettings[u].blocked ) users.push(data); return users; }, []); users.sort(this.constructor._sortUsers); // Maximum Z of all user popout windows this.maxZ = Math.max(...users.map(u => userSettings[u.user.id].z)); // Define a dynamic class for the camera dock container which affects its rendered style const dockClass = [`camera-position-${settings.client.dockPosition}`]; if ( !users.some(u => !u.settings.popout) ) dockClass.push("webrtc-dock-empty"); if ( settings.client.hideDock ) dockClass.push("webrtc-dock-minimized"); if ( this.hidden ) dockClass.push("hidden"); // Alter the body class depending on whether the players list is hidden const playersVisible = !settings.client.hidePlayerList || settings.client.hideDock; document.body.classList.toggle("players-hidden", playersVisible); const nameplateModes = AVSettings.NAMEPLATE_MODES; const nameplateSetting = settings.client.nameplates ?? nameplateModes.BOTH; const nameplates = { cssClass: [ nameplateSetting === nameplateModes.OFF ? "hidden" : "", [nameplateModes.PLAYER_ONLY, nameplateModes.CHAR_ONLY].includes(nameplateSetting) ? "noanimate" : "" ].filterJoin(" "), playerName: [nameplateModes.BOTH, nameplateModes.PLAYER_ONLY].includes(nameplateSetting), charname: [nameplateModes.BOTH, nameplateModes.CHAR_ONLY].includes(nameplateSetting) }; // Return data for rendering return { self: game.user, muteAll: settings.muteAll, borderColors: settings.client.borderColors, dockClass: dockClass.join(" "), hidden: this.hidden, users, nameplates }; } /* -------------------------------------------- */ /** * Prepare rendering data for a single user * @private */ _getDataForUser(userId, settings) { const user = game.users.get(userId); if ( !user || !user.active ) return null; const charname = user.character ? user.character.name.split(" ")[0] : ""; // CSS classes for the frame const frameClass = settings.popout ? "camera-box-popout" : "camera-box-dock"; const audioClass = this.webrtc.canUserShareAudio(userId) ? null : "no-audio"; const videoClass = this.webrtc.canUserShareVideo(userId) ? null : "no-video"; // Return structured User data return { user, settings, local: user.isSelf, charname: user.isGM ? game.i18n.localize("USER.GM") : charname, volume: foundry.audio.AudioHelper.volumeToInput(settings.volume), cameraViewClass: [frameClass, videoClass, audioClass].filterJoin(" ") }; } /* -------------------------------------------- */ /** * A custom sorting function that orders/arranges the user display frames * @return {number} * @private */ static _sortUsers(a, b) { const as = a.settings; const bs = b.settings; if (as.popout && bs.popout) return as.z - bs.z; // Sort popouts by z-index if (as.popout) return -1; // Show popout feeds first if (bs.popout) return 1; if (a.user.isSelf) return -1; // Show local feed first if (b.user.isSelf) return 1; if (a.hasVideo && !b.hasVideo) return -1; // Show remote users with a camera before those without if (b.hasVideo && !a.hasVideo) return 1; return a.user.sort - b.user.sort; // Sort according to user order } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @override */ activateListeners(html) { // Display controls when hovering over the video container let cvh = this._onCameraViewHover.bind(this); html.find(".camera-view").hover(cvh, cvh); // Handle clicks on AV control buttons html.find(".av-control").click(this._onClickControl.bind(this)); // Handle volume changes html.find(".webrtc-volume-slider").change(this._onVolumeChange.bind(this)); // Handle user controls. this._refreshView(html.find(".user-controls")[0]?.dataset.user); // Hide Global permission icons depending on the A/V mode const mode = this.webrtc.mode; if ( mode === AVSettings.AV_MODES.VIDEO ) html.find('[data-action="toggle-audio"]').hide(); if ( mode === AVSettings.AV_MODES.AUDIO ) html.find('[data-action="toggle-video"]').hide(); // Make each popout window draggable for ( let popout of this.element.find(".app.camera-view-popout") ) { let box = popout.querySelector(".camera-view"); new CameraPopoutAppWrapper(this, box.dataset.user, $(popout)); } // Listen to the video's srcObjectSet event to set the display mode of the user. for ( let video of this.element.find("video") ) { const view = video.closest(".camera-view"); this._refreshView(view.dataset.user); video.addEventListener("webrtcVideoSet", ev => { const view = video.closest(".camera-view"); if ( view.dataset.user !== ev.detail ) return; this._refreshView(view.dataset.user); }); } } /* -------------------------------------------- */ /** * On hover in a camera container, show/hide the controls. * @event {Event} event The original mouseover or mouseout hover event * @private */ _onCameraViewHover(event) { this._toggleControlVisibility(event.currentTarget, event.type === "mouseenter", null); } /* -------------------------------------------- */ /** * On clicking on a toggle, disable/enable the audio or video stream. * @event {MouseEvent} event The originating click event * @private */ async _onClickControl(event) { event.preventDefault(); // Reference relevant data const button = event.currentTarget; const action = button.dataset.action; const userId = button.closest(".camera-view, .user-controls")?.dataset.user; const user = game.users.get(userId); const settings = this.webrtc.settings; const userSettings = settings.getUser(user.id); // Handle different actions switch ( action ) { // Globally block video case "block-video": if ( !game.user.isGM ) return; await user.update({"permissions.BROADCAST_VIDEO": !userSettings.canBroadcastVideo}); return this._refreshView(userId); // Globally block audio case "block-audio": if ( !game.user.isGM ) return; await user.update({"permissions.BROADCAST_AUDIO": !userSettings.canBroadcastAudio}); return this._refreshView(userId); // Hide the user case "hide-user": if ( user.isSelf ) return; await settings.set("client", `users.${user.id}.blocked`, !userSettings.blocked); return this.render(); // Toggle video display case "toggle-video": if ( !user.isSelf ) return; if ( userSettings.hidden && !userSettings.canBroadcastVideo ) { return ui.notifications.warn("WEBRTC.WarningCannotEnableVideo", {localize: true}); } await settings.set("client", `users.${user.id}.hidden`, !userSettings.hidden); return this._refreshView(userId); // Toggle audio output case "toggle-audio": if ( !user.isSelf ) return; if ( userSettings.muted && !userSettings.canBroadcastAudio ) { return ui.notifications.warn("WEBRTC.WarningCannotEnableAudio", {localize: true}); } await settings.set("client", `users.${user.id}.muted`, !userSettings.muted); return this._refreshView(userId); // Toggle mute all peers case "mute-peers": if ( !user.isSelf ) return; await settings.set("client", "muteAll", !settings.client.muteAll); return this._refreshView(userId); // Disable sending and receiving video case "disable-video": if ( !user.isSelf ) return; await settings.set("client", "disableVideo", !settings.client.disableVideo); return this._refreshView(userId); // Configure settings case "configure": return this.webrtc.config.render(true); // Toggle popout case "toggle-popout": await settings.set("client", `users.${user.id}.popout`, !userSettings.popout); return this.render(); // Hide players case "toggle-players": await settings.set("client", "hidePlayerList", !settings.client.hidePlayerList); return this.render(); // Minimize the dock case "toggle-dock": await settings.set("client", "hideDock", !settings.client.hideDock); return this.render(); } } /* -------------------------------------------- */ /** * Change volume control for a stream * @param {Event} event The originating change event from interaction with the range input * @private */ _onVolumeChange(event) { const input = event.currentTarget; const box = input.closest(".camera-view"); const userId = box.dataset.user; let volume = foundry.audio.AudioHelper.inputToVolume(input.value); box.getElementsByTagName("video")[0].volume = volume; this.webrtc.settings.set("client", `users.${userId}.volume`, volume); } /* -------------------------------------------- */ /* Internal Helpers */ /* -------------------------------------------- */ /** * Dynamically refresh the state of a single camera view * @param {string} userId The ID of the user whose view we want to refresh. * @protected */ _refreshView(userId) { const view = this.element[0].querySelector(`.camera-view[data-user="${userId}"]`); const isSelf = game.user.id === userId; const clientSettings = game.webrtc.settings.client; const userSettings = game.webrtc.settings.getUser(userId); const minimized = clientSettings.hideDock; const isVertical = game.webrtc.settings.verticalDock; // Identify permissions const cbv = game.webrtc.canUserBroadcastVideo(userId); const csv = game.webrtc.canUserShareVideo(userId); const cba = game.webrtc.canUserBroadcastAudio(userId); const csa = game.webrtc.canUserShareAudio(userId); // Refresh video display const video = view.querySelector("video.user-camera"); const avatar = view.querySelector("img.user-avatar"); if ( video && avatar ) { const showVideo = csv && (isSelf || !clientSettings.disableVideo) && (!minimized || userSettings.popout); video.style.visibility = showVideo ? "visible" : "hidden"; video.style.display = showVideo ? "block" : "none"; avatar.style.display = showVideo ? "none" : "unset"; } // Hidden and muted status icons view.querySelector(".status-hidden")?.classList.toggle("hidden", csv); view.querySelector(".status-muted")?.classList.toggle("hidden", csa); // Volume bar and video output volume if ( video ) { video.volume = userSettings.volume; video.muted = isSelf || clientSettings.muteAll; // Mute your own video } const volBar = this.element[0].querySelector(`[data-user="${userId}"] .volume-bar`); if ( volBar ) { const displayBar = (userId !== game.user.id) && cba; volBar.style.display = displayBar ? "block" : "none"; volBar.disabled = !displayBar; } // Control toggle states const actions = { "block-video": {state: !cbv, display: game.user.isGM && !isSelf}, "block-audio": {state: !cba, display: game.user.isGM && !isSelf}, "hide-user": {state: !userSettings.blocked, display: !isSelf}, "toggle-video": {state: !csv, display: isSelf && !minimized}, "toggle-audio": {state: !csa, display: isSelf}, "mute-peers": {state: clientSettings.muteAll, display: isSelf}, "disable-video": {state: clientSettings.disableVideo, display: isSelf && !minimized}, "toggle-players": {state: !clientSettings.hidePlayerList, display: isSelf && !minimized && isVertical}, "toggle-dock": {state: !clientSettings.hideDock, display: isSelf} }; const toggles = this.element[0].querySelectorAll(`[data-user="${userId}"] .av-control.toggle`); for ( let button of toggles ) { const action = button.dataset.action; if ( !(action in actions) ) continue; const state = actions[action].state; const displayed = actions[action].display; button.style.display = displayed ? "block" : "none"; button.enabled = displayed; button.children[0].classList.remove(this._getToggleIcon(action, !state)); button.children[0].classList.add(this._getToggleIcon(action, state)); button.dataset.tooltip = this._getToggleTooltip(action, state); } } /* -------------------------------------------- */ /** * Render changes needed to the PlayerList ui. * Show/Hide players depending on option. * @private */ _setPlayerListVisibility() { const hidePlayerList = this.webrtc.settings.client.hidePlayerList; const players = document.getElementById("players"); const top = document.getElementById("ui-top"); if ( players ) players.classList.toggle("hidden", hidePlayerList); if ( top ) top.classList.toggle("offset", !hidePlayerList); } /* -------------------------------------------- */ /** * Get the icon class that should be used for various action buttons with different toggled states. * The returned icon should represent the visual status of the NEXT state (not the CURRENT state). * * @param {string} action The named av-control button action * @param {boolean} state The CURRENT action state. * @returns {string} The icon that represents the NEXT action state. * @protected */ _getToggleIcon(action, state) { const clientSettings = game.webrtc.settings.client; const dockPositions = AVSettings.DOCK_POSITIONS; const dockIcons = { [dockPositions.TOP]: {collapse: "down", expand: "up"}, [dockPositions.RIGHT]: {collapse: "left", expand: "right"}, [dockPositions.BOTTOM]: {collapse: "up", expand: "down"}, [dockPositions.LEFT]: {collapse: "right", expand: "left"} }[clientSettings.dockPosition]; const actionMapping = { "block-video": ["fa-video", "fa-video-slash"], // True means "blocked" "block-audio": ["fa-microphone", "fa-microphone-slash"], // True means "blocked" "hide-user": ["fa-eye", "fa-eye-slash"], "toggle-video": ["fa-camera-web", "fa-camera-web-slash"], // True means "enabled" "toggle-audio": ["fa-microphone", "fa-microphone-slash"], // True means "enabled" "mute-peers": ["fa-volume-up", "fa-volume-mute"], // True means "muted" "disable-video": ["fa-video", "fa-video-slash"], "toggle-players": ["fa-caret-square-right", "fa-caret-square-left"], // True means "displayed" "toggle-dock": [`fa-caret-square-${dockIcons.collapse}`, `fa-caret-square-${dockIcons.expand}`] }; const icons = actionMapping[action]; return icons ? icons[state ? 1: 0] : null; } /* -------------------------------------------- */ /** * Get the text title that should be used for various action buttons with different toggled states. * The returned title should represent the tooltip of the NEXT state (not the CURRENT state). * * @param {string} action The named av-control button action * @param {boolean} state The CURRENT action state. * @returns {string} The icon that represents the NEXT action state. * @protected */ _getToggleTooltip(action, state) { const actionMapping = { "block-video": ["BlockUserVideo", "AllowUserVideo"], // True means "blocked" "block-audio": ["BlockUserAudio", "AllowUserAudio"], // True means "blocked" "hide-user": ["ShowUser", "HideUser"], "toggle-video": ["DisableMyVideo", "EnableMyVideo"], // True means "enabled" "toggle-audio": ["DisableMyAudio", "EnableMyAudio"], // True means "enabled" "mute-peers": ["MutePeers", "UnmutePeers"], // True means "muted" "disable-video": ["DisableAllVideo", "EnableVideo"], "toggle-players": ["ShowPlayers", "HidePlayers"], // True means "displayed" "toggle-dock": ["ExpandDock", "MinimizeDock"] }; const labels = actionMapping[action]; return game.i18n.localize(`WEBRTC.Tooltip${labels ? labels[state ? 1 : 0] : ""}`); } /* -------------------------------------------- */ /** * Show or hide UI control elements * This replaces the use of jquery.show/hide as it simply adds a class which has display:none * which allows us to have elements with display:flex which can be hidden then shown without * breaking their display style. * This will show/hide the toggle buttons, volume controls and overlay sidebars * @param {jQuery} container The container for which to show/hide control elements * @param {boolean} show Whether to show or hide the controls * @param {string} selector Override selector to specify which controls to show or hide * @private */ _toggleControlVisibility(container, show, selector) { selector = selector || `.control-bar`; container.querySelectorAll(selector).forEach(c => c.classList.toggle("hidden", !show)); } }