Files
Foundry-VTT-Docker/resources/app/client/apps/av/cameras.js
2025-01-04 00:34:03 +01:00

558 lines
22 KiB
JavaScript

/**
* 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));
}
}