Initial
This commit is contained in:
207
resources/app/client/apps/av/av-config.js
Normal file
207
resources/app/client/apps/av/av-config.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Audio/Video Conferencing Configuration Sheet
|
||||
* @extends {FormApplication}
|
||||
*
|
||||
* @param {AVMaster} object The {@link AVMaster} instance being configured.
|
||||
* @param {FormApplicationOptions} [options] Application configuration options.
|
||||
*/
|
||||
class AVConfig extends FormApplication {
|
||||
constructor(object, options) {
|
||||
super(object || game.webrtc, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
title: game.i18n.localize("WEBRTC.Title"),
|
||||
id: "av-config",
|
||||
template: "templates/sidebar/apps/av-config.html",
|
||||
popOut: true,
|
||||
width: 480,
|
||||
height: "auto",
|
||||
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "general"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
const settings = this.object.settings;
|
||||
const videoSources = await this.object.client.getVideoSources();
|
||||
const audioSources = await this.object.client.getAudioSources();
|
||||
const audioSinks = await this.object.client.getAudioSinks();
|
||||
|
||||
// If the currently chosen device is unavailable, display a separate option for 'unavailable device (use default)'
|
||||
const { videoSrc, audioSrc, audioSink } = settings.client;
|
||||
const videoSrcUnavailable = this._isSourceUnavailable(videoSources, videoSrc);
|
||||
const audioSrcUnavailable = this._isSourceUnavailable(audioSources, audioSrc);
|
||||
const audioSinkUnavailable = this._isSourceUnavailable(audioSinks, audioSink);
|
||||
const isSSL = window.location.protocol === "https:";
|
||||
|
||||
// Audio/Video modes
|
||||
const modes = {
|
||||
[AVSettings.AV_MODES.DISABLED]: "WEBRTC.ModeDisabled",
|
||||
[AVSettings.AV_MODES.AUDIO]: "WEBRTC.ModeAudioOnly",
|
||||
[AVSettings.AV_MODES.VIDEO]: "WEBRTC.ModeVideoOnly",
|
||||
[AVSettings.AV_MODES.AUDIO_VIDEO]: "WEBRTC.ModeAudioVideo"
|
||||
};
|
||||
|
||||
// Voice Broadcast modes
|
||||
const voiceModes = Object.values(AVSettings.VOICE_MODES).reduce((obj, m) => {
|
||||
obj[m] = game.i18n.localize(`WEBRTC.VoiceMode${m.titleCase()}`);
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
// Nameplate settings.
|
||||
const nameplates = {
|
||||
[AVSettings.NAMEPLATE_MODES.OFF]: "WEBRTC.NameplatesOff",
|
||||
[AVSettings.NAMEPLATE_MODES.PLAYER_ONLY]: "WEBRTC.NameplatesPlayer",
|
||||
[AVSettings.NAMEPLATE_MODES.CHAR_ONLY]: "WEBRTC.NameplatesCharacter",
|
||||
[AVSettings.NAMEPLATE_MODES.BOTH]: "WEBRTC.NameplatesBoth"
|
||||
};
|
||||
|
||||
const dockPositions = Object.fromEntries(Object.values(AVSettings.DOCK_POSITIONS).map(p => {
|
||||
return [p, game.i18n.localize(`WEBRTC.DockPosition${p.titleCase()}`)];
|
||||
}));
|
||||
|
||||
// Return data to the template
|
||||
return {
|
||||
user: game.user,
|
||||
modes,
|
||||
voiceModes,
|
||||
serverTypes: {FVTT: "WEBRTC.FVTTSignalingServer", custom: "WEBRTC.CustomSignalingServer"},
|
||||
turnTypes: {server: "WEBRTC.TURNServerProvisioned", custom: "WEBRTC.CustomTURNServer"},
|
||||
settings,
|
||||
canSelectMode: game.user.isGM && isSSL,
|
||||
noSSL: !isSSL,
|
||||
videoSources,
|
||||
audioSources,
|
||||
audioSinks: foundry.utils.isEmpty(audioSinks) ? false : audioSinks,
|
||||
videoSrcUnavailable,
|
||||
audioSrcUnavailable,
|
||||
audioSinkUnavailable,
|
||||
audioDisabled: audioSrc === "disabled",
|
||||
videoDisabled: videoSrc === "disabled",
|
||||
nameplates,
|
||||
nameplateSetting: settings.client.nameplates ?? AVSettings.NAMEPLATE_MODES.BOTH,
|
||||
dockPositions,
|
||||
audioSourceOptions: this.#getDevices(audioSources, audioSrcUnavailable, "WEBRTC.DisableAudioSource"),
|
||||
audioSinkOptions: this.#getDevices(audioSinks, audioSinkUnavailable),
|
||||
videoSourceOptions: this.#getDevices(videoSources, videoSrcUnavailable, "WEBRTC.DisableVideoSource")
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an array of available devices which can be chosen.
|
||||
* @param {Record<string, string>} devices
|
||||
* @param {string} unavailableDevice
|
||||
* @param {string} disabledLabel
|
||||
* @returns {FormSelectOption[]}
|
||||
*/
|
||||
#getDevices(devices, unavailableDevice, disabledLabel) {
|
||||
const options = [];
|
||||
let hasDefault = false;
|
||||
for ( const [k, v] of Object.entries(devices) ) {
|
||||
if ( k === "default" ) hasDefault = true;
|
||||
options.push({value: k, label: v});
|
||||
}
|
||||
if ( !hasDefault ) {
|
||||
options.unshift({value: "default", label: game.i18n.localize("WEBRTC.DefaultSource")});
|
||||
}
|
||||
if ( disabledLabel ) {
|
||||
options.unshift({value: "disabled", label: game.i18n.localize(disabledLabel)});
|
||||
}
|
||||
if ( unavailableDevice ) {
|
||||
options.push({value: unavailableDevice, label: game.i18n.localize("WEBRTC.UnavailableDevice")});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Options below are GM only
|
||||
if ( !game.user.isGM ) return;
|
||||
html.find('select[name="world.turn.type"]').change(this._onTurnTypeChanged.bind(this));
|
||||
|
||||
// Activate or de-activate the custom server and turn configuration sections based on current settings
|
||||
const settings = this.object.settings;
|
||||
this._setConfigSectionEnabled(".webrtc-custom-turn-config", settings.world.turn.type === "custom");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set a section's input to enabled or disabled
|
||||
* @param {string} selector Selector for the section to enable or disable
|
||||
* @param {boolean} enabled Whether to enable or disable this section
|
||||
* @private
|
||||
*/
|
||||
_setConfigSectionEnabled(selector, enabled = true) {
|
||||
let section = this.element.find(selector);
|
||||
if (section) {
|
||||
section.css("opacity", enabled ? 1.0 : 0.5);
|
||||
section.find("input").prop("disabled", !enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine whether a given video or audio source, or audio sink has become
|
||||
* unavailable since the last time it was set.
|
||||
* @param {object} sources The available devices
|
||||
* @param {string} source The selected device
|
||||
* @private
|
||||
*/
|
||||
_isSourceUnavailable(sources, source) {
|
||||
const specialValues = ["default", "disabled"];
|
||||
return source && (!specialValues.includes(source)) && !Object.keys(sources).includes(source);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Callback when the turn server type changes
|
||||
* Will enable or disable the turn section based on whether the user selected a custom turn or not
|
||||
* @param {Event} event The event that triggered the turn server type change
|
||||
* @private
|
||||
*/
|
||||
_onTurnTypeChanged(event) {
|
||||
event.preventDefault();
|
||||
const choice = event.currentTarget.value;
|
||||
this._setConfigSectionEnabled(".webrtc-custom-turn-config", choice === "custom")
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const settings = game.webrtc.settings;
|
||||
settings.client.videoSrc = settings.client.videoSrc || null;
|
||||
settings.client.audioSrc = settings.client.audioSrc || null;
|
||||
|
||||
const update = foundry.utils.expandObject(formData);
|
||||
|
||||
// Update world settings
|
||||
if ( game.user.isGM ) {
|
||||
if ( settings.world.mode !== update.world.mode ) SettingsConfig.reloadConfirm({world: true});
|
||||
const world = foundry.utils.mergeObject(settings.world, update.world);
|
||||
await game.settings.set("core", "rtcWorldSettings", world);
|
||||
}
|
||||
|
||||
// Update client settings
|
||||
const client = foundry.utils.mergeObject(settings.client, update.client);
|
||||
await game.settings.set("core", "rtcClientSettings", client);
|
||||
}
|
||||
}
|
||||
68
resources/app/client/apps/av/camera-popout.js
Normal file
68
resources/app/client/apps/av/camera-popout.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Abstraction of the Application interface to be used with the Draggable class as a substitute for the app
|
||||
* This class will represent one popout feed window and handle its positioning and draggability
|
||||
* @param {CameraViews} view The CameraViews application that this popout belongs to
|
||||
* @param {string} userId ID of the user this popout belongs to
|
||||
* @param {jQuery} element The div element of this specific popout window
|
||||
*/
|
||||
class CameraPopoutAppWrapper {
|
||||
constructor(view, userId, element) {
|
||||
this.view = view;
|
||||
this.element = element;
|
||||
this.userId = userId;
|
||||
|
||||
// "Fake" some application attributes
|
||||
this.popOut = true;
|
||||
this.options = {};
|
||||
|
||||
// Get the saved position
|
||||
let setting = game.webrtc.settings.getUser(userId);
|
||||
this.setPosition(setting);
|
||||
new Draggable(this, element.find(".camera-view"), element.find(".video-container")[0], true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the current position of this popout window
|
||||
*/
|
||||
get position() {
|
||||
return foundry.utils.mergeObject(this.element.position(), {
|
||||
width: this.element.outerWidth(),
|
||||
height: this.element.outerHeight(),
|
||||
scale: 1
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
setPosition(options={}) {
|
||||
const position = Application.prototype.setPosition.call(this, options);
|
||||
// Let the HTML renderer figure out the height based on width.
|
||||
this.element[0].style.height = "";
|
||||
if ( !foundry.utils.isEmpty(position) ) {
|
||||
const current = game.webrtc.settings.client.users[this.userId] || {};
|
||||
const update = foundry.utils.mergeObject(current, position);
|
||||
game.webrtc.settings.set("client", `users.${this.userId}`, update);
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_onResize(event) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
bringToTop() {
|
||||
let parent = this.element.parent();
|
||||
let children = parent.children();
|
||||
let lastElement = children[children.length - 1];
|
||||
if (lastElement !== this.element[0]) {
|
||||
game.webrtc.settings.set("client", `users.${this.userId}.z`, ++this.view.maxZ);
|
||||
parent.append(this.element);
|
||||
}
|
||||
}
|
||||
}
|
||||
557
resources/app/client/apps/av/cameras.js
Normal file
557
resources/app/client/apps/av/cameras.js
Normal file
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user