mirror of
https://github.com/overte-org/overte.git
synced 2025-04-05 20:09:01 +02:00
946 lines
36 KiB
JavaScript
946 lines
36 KiB
JavaScript
"use strict";
|
|
//
|
|
// pal.js
|
|
//
|
|
// Created by Howard Stearns on December 9th, 2016
|
|
// Copyright 2016 High Fidelity, Inc.
|
|
// Copyright 2023 Overte e.V.
|
|
//
|
|
// Distributed under the Apache License, Version 2.0
|
|
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
|
|
//
|
|
/* jslint vars:true, plusplus:true, forin:true */
|
|
/* global Tablet, Settings, Script, AvatarList, Users, Entities,
|
|
MyAvatar, Camera, Overlays, Vec3, Quat, HMD, Controller, Account,
|
|
UserActivityLogger, Messages, Window, XMLHttpRequest, print, location, getControllerWorldLocation
|
|
*/
|
|
/* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */
|
|
//
|
|
|
|
(function () { // BEGIN LOCAL_SCOPE
|
|
var controllerStandard = Controller.Standard;
|
|
|
|
var request = Script.require('request').request;
|
|
var AppUi = Script.require('appUi');
|
|
|
|
var populateNearbyUserList, color, textures, removeOverlays,
|
|
controllerComputePickRay, off,
|
|
receiveMessage, avatarDisconnected, clearLocalQMLDataAndClosePAL,
|
|
CHANNEL, getConnectionData,
|
|
avatarAdded, avatarRemoved, avatarSessionChanged; // forward references;
|
|
|
|
// hardcoding these as it appears we cannot traverse the originalTextures in overlays??? Maybe I've missed
|
|
// something, will revisit as this is sorta horrible.
|
|
var UNSELECTED_TEXTURES = {
|
|
"idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png"),
|
|
"idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png")
|
|
};
|
|
var SELECTED_TEXTURES = {
|
|
"idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png"),
|
|
"idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png")
|
|
};
|
|
var HOVER_TEXTURES = {
|
|
"idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png"),
|
|
"idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png")
|
|
};
|
|
|
|
var UNSELECTED_COLOR = { red: 0x1F, green: 0xC6, blue: 0xA6};
|
|
var SELECTED_COLOR = {red: 0xF3, green: 0x91, blue: 0x29};
|
|
var HOVER_COLOR = {red: 0xD0, green: 0xD0, blue: 0xD0}; // almost white for now
|
|
var METAVERSE_BASE = Account.metaverseServerURL;
|
|
|
|
Script.include("/~/system/libraries/controllers.js");
|
|
|
|
function projectVectorOntoPlane(normalizedVector, planeNormal) {
|
|
return Vec3.cross(planeNormal, Vec3.cross(normalizedVector, planeNormal));
|
|
}
|
|
function angleBetweenVectorsInPlane(from, to, normal) {
|
|
var projectedFrom = projectVectorOntoPlane(from, normal);
|
|
var projectedTo = projectVectorOntoPlane(to, normal);
|
|
return Vec3.orientedAngle(projectedFrom, projectedTo, normal);
|
|
}
|
|
|
|
//
|
|
// Overlays.
|
|
//
|
|
var overlays = {}; // Keeps track of all our extended overlay data objects, keyed by target identifier.
|
|
|
|
function ExtendedOverlay(key, properties, selected, hasModel) { // A wrapper around overlays to store the key it is associated with.
|
|
overlays[key] = this;
|
|
if (hasModel) {
|
|
var modelKey = key + "-m";
|
|
this.model = new ExtendedOverlay(modelKey, {
|
|
"type": "Model",
|
|
"modelURL": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx"),
|
|
"textures": textures(selected),
|
|
"ignorePickIntersection": true
|
|
}, false, false);
|
|
} else {
|
|
this.model = undefined;
|
|
}
|
|
this.key = key;
|
|
this.selected = selected || false; // not undefined
|
|
this.hovering = false;
|
|
this.activeOverlay = Entities.addEntity(properties, "local"); // We could use different overlays for (un)selected...
|
|
}
|
|
// Instance methods:
|
|
ExtendedOverlay.prototype.deleteOverlay = function () { // remove display and data of this overlay
|
|
Entities.deleteEntity(this.activeOverlay);
|
|
delete overlays[this.key];
|
|
};
|
|
|
|
ExtendedOverlay.prototype.editOverlay = function (properties) { // change display of this overlay
|
|
Entities.editEntity(this.activeOverlay, properties);
|
|
};
|
|
|
|
function color(selected, hovering, level) {
|
|
var base = hovering ? HOVER_COLOR : selected ? SELECTED_COLOR : UNSELECTED_COLOR;
|
|
function scale(component) {
|
|
var delta = 0xFF - component;
|
|
return component + (delta * level);
|
|
}
|
|
return {red: scale(base.red), green: scale(base.green), blue: scale(base.blue)};
|
|
}
|
|
|
|
function textures(selected, hovering) {
|
|
return hovering ? HOVER_TEXTURES : selected ? SELECTED_TEXTURES : UNSELECTED_TEXTURES;
|
|
}
|
|
// so we don't have to traverse the overlays to get the last one
|
|
var lastHoveringId = 0;
|
|
ExtendedOverlay.prototype.hover = function (hovering) {
|
|
this.hovering = hovering;
|
|
if (this.key === lastHoveringId) {
|
|
if (hovering) {
|
|
return;
|
|
}
|
|
lastHoveringId = 0;
|
|
}
|
|
this.editOverlay({color: color(this.selected, hovering, this.audioLevel)});
|
|
if (this.model) {
|
|
this.model.editOverlay({textures: textures(this.selected, hovering)});
|
|
}
|
|
if (hovering) {
|
|
// un-hover the last hovering overlay
|
|
if (lastHoveringId && lastHoveringId !== this.key) {
|
|
ExtendedOverlay.get(lastHoveringId).hover(false);
|
|
}
|
|
lastHoveringId = this.key;
|
|
}
|
|
};
|
|
ExtendedOverlay.prototype.select = function (selected) {
|
|
if (this.selected === selected) {
|
|
return;
|
|
}
|
|
|
|
UserActivityLogger.palAction(selected ? "avatar_selected" : "avatar_deselected", this.key);
|
|
|
|
this.editOverlay({color: color(selected, this.hovering, this.audioLevel)});
|
|
if (this.model) {
|
|
this.model.editOverlay({textures: textures(selected)});
|
|
}
|
|
this.selected = selected;
|
|
};
|
|
// Class methods:
|
|
var selectedIds = [];
|
|
ExtendedOverlay.isSelected = function (id) {
|
|
return -1 !== selectedIds.indexOf(id);
|
|
};
|
|
ExtendedOverlay.get = function (key) { // answer the extended overlay data object associated with the given avatar identifier
|
|
return overlays[key];
|
|
};
|
|
ExtendedOverlay.some = function (iterator) { // Bails early as soon as iterator returns truthy.
|
|
var key;
|
|
for (key in overlays) {
|
|
if (iterator(ExtendedOverlay.get(key))) {
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
ExtendedOverlay.unHover = function () { // calls hover(false) on lastHoveringId (if any)
|
|
if (lastHoveringId) {
|
|
ExtendedOverlay.get(lastHoveringId).hover(false);
|
|
}
|
|
};
|
|
|
|
// hit(overlay) on the one overlay intersected by pickRay, if any.
|
|
// noHit() if no ExtendedOverlay was intersected (helps with hover)
|
|
ExtendedOverlay.applyPickRay = function (pickRay, hit, noHit) {
|
|
// TODO: this could just include the necessary overlays for better performance
|
|
var pickedOverlay = Overlays.findRayIntersection(pickRay, true); // Depends on nearer coverOverlays to extend closer to us than farther ones.
|
|
if (!pickedOverlay.intersects) {
|
|
if (noHit) {
|
|
return noHit();
|
|
}
|
|
return;
|
|
}
|
|
ExtendedOverlay.some(function (overlay) { // See if pickedOverlay is one of ours.
|
|
if ((overlay.activeOverlay) === pickedOverlay.overlayID) {
|
|
hit(overlay);
|
|
return true;
|
|
}
|
|
});
|
|
};
|
|
|
|
|
|
//
|
|
// Similar, for entities
|
|
//
|
|
function HighlightedEntity(id, entityProperties) {
|
|
this.id = id;
|
|
this.overlay = Entities.addEntity({
|
|
"type": "Shape",
|
|
"shape": "Cube",
|
|
"position": entityProperties.position,
|
|
"rotation": entityProperties.rotation,
|
|
"dimensions": entityProperties.dimensions,
|
|
"primitiveMode": "solid",
|
|
"color": {
|
|
"red": 0xF3,
|
|
"green": 0x91,
|
|
"blue": 0x29
|
|
},
|
|
"ignorePickIntersection": true,
|
|
"renderLayer": "world" // Arguable. For now, let's not distract with mysterious wires around the scene.
|
|
}, "local");
|
|
HighlightedEntity.overlays.push(this);
|
|
}
|
|
HighlightedEntity.overlays = [];
|
|
HighlightedEntity.clearOverlays = function clearHighlightedEntities() {
|
|
HighlightedEntity.overlays.forEach(function (highlighted) {
|
|
Entities.deleteEntity(highlighted.overlay);
|
|
});
|
|
HighlightedEntity.overlays = [];
|
|
};
|
|
HighlightedEntity.updateOverlays = function updateHighlightedEntities() {
|
|
HighlightedEntity.overlays.forEach(function (highlighted) {
|
|
var properties = Entities.getEntityProperties(highlighted.id, ['position', 'rotation', 'dimensions']);
|
|
Entities.editEntity(highlighted.overlay, {
|
|
"position": properties.position,
|
|
"rotation": properties.rotation,
|
|
"dimensions": properties.dimensions
|
|
});
|
|
});
|
|
};
|
|
|
|
/* this contains current gain for a given node (by session id). More efficient than
|
|
* querying it, plus there isn't a getGain function so why write one */
|
|
var sessionGains = {};
|
|
function convertDbToLinear(decibels) {
|
|
// +20db = 10x, 0dB = 1x, -10dB = 0.1x, etc...
|
|
// but, your perception is that something 2x as loud is +10db
|
|
// so we go from -60 to +20 or 1/64x to 4x. For now, we can
|
|
// maybe scale the signal this way??
|
|
return Math.pow(2, decibels / 10.0);
|
|
}
|
|
function fromQml(message) { // messages are {method, params}, like json-rpc. See also sendToQml.
|
|
var data, connectionUserName, friendUserName;
|
|
switch (message.method) {
|
|
case 'selected':
|
|
selectedIds = message.params;
|
|
ExtendedOverlay.some(function (overlay) {
|
|
var id = overlay.key;
|
|
var selected = ExtendedOverlay.isSelected(id);
|
|
overlay.select(selected);
|
|
});
|
|
|
|
HighlightedEntity.clearOverlays();
|
|
if (selectedIds.length) {
|
|
Entities.findEntitiesInFrustum(Camera.frustum).forEach(function (id) {
|
|
// Because lastEditedBy is per session, the vast majority of entities won't match,
|
|
// so it would probably be worth reducing marshalling costs by asking for just we need.
|
|
// However, providing property name(s) is advisory and some additional properties are
|
|
// included anyway. As it turns out, asking for 'lastEditedBy' gives 'position', 'rotation',
|
|
// and 'dimensions', too, so we might as well make use of them instead of making a second
|
|
// getEntityProperties call.
|
|
// It would be nice if we could harden this against future changes by specifying all
|
|
// and only these four in an array, but see
|
|
// https://highfidelity.fogbugz.com/f/cases/2728/Entities-getEntityProperties-id-lastEditedBy-name-lastEditedBy-doesn-t-work
|
|
var properties = Entities.getEntityProperties(id, 'lastEditedBy');
|
|
if (ExtendedOverlay.isSelected(properties.lastEditedBy)) {
|
|
new HighlightedEntity(id, properties);
|
|
}
|
|
});
|
|
}
|
|
break;
|
|
case 'refresh': // old name for refreshNearby
|
|
case 'refreshNearby':
|
|
data = {};
|
|
ExtendedOverlay.some(function (overlay) { // capture the audio data
|
|
data[overlay.key] = overlay;
|
|
});
|
|
removeOverlays();
|
|
// If filter is specified from .qml instead of through settings, update the settings.
|
|
if (message.params.filter !== undefined) {
|
|
Settings.setValue('pal/filtered', !!message.params.filter);
|
|
}
|
|
populateNearbyUserList(message.params.selected, data);
|
|
UserActivityLogger.palAction("refresh_nearby", "");
|
|
break;
|
|
case 'refreshConnections':
|
|
print('Refreshing Connections...');
|
|
UserActivityLogger.palAction("refresh_connections", "");
|
|
break;
|
|
case 'removeConnection':
|
|
connectionUserName = message.params;
|
|
request({
|
|
uri: METAVERSE_BASE + '/api/v1/user/connections/' + connectionUserName,
|
|
method: 'DELETE'
|
|
}, function (error, response) {
|
|
if (error || (response.status !== 'success')) {
|
|
print("Error: unable to remove connection", connectionUserName, error || response.status);
|
|
return;
|
|
}
|
|
sendToQml({ method: 'connectionRemoved', params: connectionUserName });
|
|
});
|
|
break;
|
|
|
|
case 'removeFriend':
|
|
friendUserName = message.params;
|
|
print("Removing " + friendUserName + " from friends.");
|
|
request({
|
|
uri: METAVERSE_BASE + '/api/v1/user/friends/' + friendUserName,
|
|
method: 'DELETE'
|
|
}, function (error, response) {
|
|
if (error || (response.status !== 'success')) {
|
|
print("Error: unable to unfriend " + friendUserName, error || response.status);
|
|
return;
|
|
}
|
|
getConnectionData(friendUserName);
|
|
});
|
|
break;
|
|
case 'addFriend':
|
|
friendUserName = message.params;
|
|
print("Adding " + friendUserName + " to friends.");
|
|
request({
|
|
uri: METAVERSE_BASE + '/api/v1/user/friends',
|
|
method: 'POST',
|
|
json: true,
|
|
body: {
|
|
username: friendUserName,
|
|
}
|
|
}, function (error, response) {
|
|
if (error || (response.status !== 'success')) {
|
|
print("Error: unable to friend " + friendUserName, error || response.status);
|
|
return;
|
|
}
|
|
getConnectionData(friendUserName);
|
|
});
|
|
break;
|
|
case 'http.request':
|
|
break; // Handled by request-service.
|
|
case 'hideNotificationDot':
|
|
shouldShowDot = false;
|
|
ui.messagesWaiting(shouldShowDot);
|
|
break;
|
|
default:
|
|
print('Unrecognized message from Pal.qml');
|
|
}
|
|
}
|
|
|
|
function sendToQml(message) {
|
|
ui.sendMessage(message);
|
|
}
|
|
function updateUser(data) {
|
|
print('PAL update:', JSON.stringify(data));
|
|
sendToQml({ method: 'updateUsername', params: data });
|
|
}
|
|
//
|
|
// User management services
|
|
//
|
|
// These are prototype versions that will be changed when the back end changes.
|
|
|
|
function requestJSON(url, callback) { // callback(data) if successfull. Logs otherwise.
|
|
request({
|
|
uri: url
|
|
}, function (error, response) {
|
|
if (error || (response.status !== 'success')) {
|
|
print("Error: unable to get request", error || response.status);
|
|
return;
|
|
}
|
|
callback(response.data);
|
|
});
|
|
}
|
|
function getProfilePicture(username, callback) { // callback(url) if successfull. (Logs otherwise)
|
|
// FIXME Prototype scrapes profile picture. We should include in user status, and also make available somewhere for myself
|
|
request({
|
|
uri: METAVERSE_BASE + '/users/' + username
|
|
}, function (error, html) {
|
|
var matched = !error && html.match(/img class="users-img" src="([^"]*)"/);
|
|
if (!matched) {
|
|
print('Error: Unable to get profile picture for', username, error);
|
|
callback('');
|
|
return;
|
|
}
|
|
callback(matched[1]);
|
|
});
|
|
}
|
|
var SAFETY_LIMIT = 400;
|
|
function getAvailableConnections(domain, callback, numResultsPerPage) { // callback([{usename, location}...]) if successfull. (Logs otherwise)
|
|
var url = METAVERSE_BASE + '/api/v1/users?per_page=' + (numResultsPerPage || SAFETY_LIMIT) + '&';
|
|
if (domain) {
|
|
url += 'status=' + domain.slice(1, -1); // without curly braces
|
|
} else {
|
|
url += 'filter=connections'; // regardless of whether online
|
|
}
|
|
requestJSON(url, function (connectionsData) {
|
|
callback(connectionsData.users);
|
|
});
|
|
}
|
|
function getInfoAboutUser(specificUsername, callback) {
|
|
var url = METAVERSE_BASE + '/api/v1/users?filter=connections&per_page=' + SAFETY_LIMIT + '&search=' + encodeURIComponent(specificUsername);
|
|
requestJSON(url, function (connectionsData) {
|
|
// You could have (up to SAFETY_LIMIT connections whose usernames contain the specificUsername.
|
|
// Search returns all such matches.
|
|
for (user in connectionsData.users) {
|
|
if (connectionsData.users[user].username === specificUsername) {
|
|
callback(connectionsData.users[user]);
|
|
return;
|
|
}
|
|
}
|
|
callback(false);
|
|
});
|
|
}
|
|
function getConnectionData(specificUsername, domain) { // Update all the usernames that I am entitled to see, using my login but not dependent on canKick.
|
|
function frob(user) { // get into the right format
|
|
var formattedSessionId = user.location.node_id || '';
|
|
if (formattedSessionId !== '' && formattedSessionId.indexOf("{") != 0) {
|
|
formattedSessionId = "{" + formattedSessionId + "}";
|
|
}
|
|
return {
|
|
sessionId: formattedSessionId,
|
|
userName: user.username,
|
|
connection: user.connection,
|
|
profileUrl: user.images.thumbnail,
|
|
placeName: (user.location.root || user.location.domain || {}).name || ''
|
|
};
|
|
}
|
|
if (specificUsername) {
|
|
getInfoAboutUser(specificUsername, function (user) {
|
|
if (user) {
|
|
updateUser(frob(user));
|
|
} else {
|
|
print('Error: Unable to find information about ' + specificUsername + ' in connectionsData!');
|
|
}
|
|
});
|
|
} else if (domain) {
|
|
getAvailableConnections(domain, function (users) {
|
|
users.forEach(function (user) {
|
|
updateUser(frob(user));
|
|
});
|
|
});
|
|
} else {
|
|
print("Error: unrecognized getConnectionData()");
|
|
}
|
|
}
|
|
|
|
//
|
|
// Main operations.
|
|
//
|
|
function addAvatarNode(id) {
|
|
var selected = ExtendedOverlay.isSelected(id);
|
|
return new ExtendedOverlay(id, {
|
|
"type": "Shape",
|
|
"shape": "Sphere",
|
|
"renderLayer": "front",
|
|
"primitiveMode": "solid",
|
|
"alpha": 0.8,
|
|
"color": color(selected, false, 0.0),
|
|
"ignorePickIntersection": false
|
|
}, selected, true);
|
|
}
|
|
// Each open/refresh will capture a stable set of avatarsOfInterest, within the specified filter.
|
|
var avatarsOfInterest = {};
|
|
function populateNearbyUserList(selectData, oldAudioData) {
|
|
var filter = Settings.getValue('pal/filtered') && {distance: Settings.getValue('pal/nearDistance')},
|
|
data = [],
|
|
avatars = AvatarList.getAvatarIdentifiers(),
|
|
myPosition = filter && Camera.position,
|
|
frustum = filter && Camera.frustum,
|
|
verticalHalfAngle = filter && (frustum.fieldOfView / 2),
|
|
horizontalHalfAngle = filter && (verticalHalfAngle * frustum.aspectRatio),
|
|
orientation = filter && Camera.orientation,
|
|
forward = filter && Quat.getForward(orientation),
|
|
verticalAngleNormal = filter && Quat.getRight(orientation),
|
|
horizontalAngleNormal = filter && Quat.getUp(orientation);
|
|
avatarsOfInterest = {};
|
|
|
|
var avatarData = AvatarList.getPalData().data;
|
|
|
|
avatarData.forEach(function (currentAvatarData) {
|
|
var id = currentAvatarData.sessionUUID;
|
|
var name = currentAvatarData.sessionDisplayName;
|
|
if (!name) {
|
|
// Either we got a data packet but no identity yet, or something is really messed up. In any case,
|
|
// we won't be able to do anything with this user, so don't include them.
|
|
// In normal circumstances, a refresh will bring in the new user, but if we're very heavily loaded,
|
|
// we could be losing and gaining people randomly.
|
|
print('No avatar identity data for', currentAvatarData.sessionUUID);
|
|
return;
|
|
}
|
|
if (id && myPosition && (Vec3.distance(currentAvatarData.position, myPosition) > filter.distance)) {
|
|
return;
|
|
}
|
|
var normal = id && filter && Vec3.normalize(Vec3.subtract(currentAvatarData.position, myPosition));
|
|
var horizontal = normal && angleBetweenVectorsInPlane(normal, forward, horizontalAngleNormal);
|
|
var vertical = normal && angleBetweenVectorsInPlane(normal, forward, verticalAngleNormal);
|
|
if (id && filter && ((Math.abs(horizontal) > horizontalHalfAngle) || (Math.abs(vertical) > verticalHalfAngle))) {
|
|
return;
|
|
}
|
|
var oldAudio = oldAudioData && oldAudioData[id];
|
|
var avatarPalDatum = {
|
|
profileUrl: '',
|
|
displayName: name,
|
|
userName: '',
|
|
connection: '',
|
|
sessionId: id || '',
|
|
audioLevel: (oldAudio && oldAudio.audioLevel) || 0.0,
|
|
avgAudioLevel: (oldAudio && oldAudio.avgAudioLevel) || 0.0,
|
|
admin: false,
|
|
personalMute: !!id && Users.getPersonalMuteStatus(id), // expects proper boolean, not null
|
|
ignore: !!id && Users.getIgnoreStatus(id), // ditto
|
|
isPresent: true,
|
|
isReplicated: currentAvatarData.isReplicated
|
|
};
|
|
// Everyone needs to see admin status. Username and fingerprint returns default constructor output if the requesting user isn't an admin.
|
|
Users.requestUsernameFromID(id);
|
|
if (id !== "") {
|
|
addAvatarNode(id); // No overlay for ourselves
|
|
avatarsOfInterest[id] = true;
|
|
} else {
|
|
// Return our username from the Account API
|
|
avatarPalDatum.userName = Account.username;
|
|
}
|
|
data.push(avatarPalDatum);
|
|
print('PAL data:', JSON.stringify(avatarPalDatum));
|
|
});
|
|
getConnectionData(false, location.domainID); // Even admins don't get relationship data in requestUsernameFromID (which is still needed for admin status, which comes from domain).
|
|
sendToQml({ method: 'nearbyUsers', params: data });
|
|
if (selectData) {
|
|
selectData[2] = true;
|
|
sendToQml({ method: 'select', params: selectData });
|
|
}
|
|
}
|
|
|
|
// The function that handles the reply from the server
|
|
function usernameFromIDReply(id, username, machineFingerprint, isAdmin) {
|
|
var data = {
|
|
sessionId: (MyAvatar.sessionUUID === id) ? '' : id, // Pal.qml recognizes empty id specially.
|
|
// If we get username (e.g., if in future we receive it when we're friends), use it.
|
|
// Otherwise, use valid machineFingerprint (which is not valid when not an admin).
|
|
userName: username || (Users.canKick && machineFingerprint) || '',
|
|
admin: isAdmin
|
|
};
|
|
// Ship the data off to QML
|
|
updateUser(data);
|
|
}
|
|
|
|
function updateAudioLevel(avatarData) {
|
|
// the VU meter should work similarly to the one in AvatarInputs: log scale, exponentially averaged
|
|
// But of course it gets the data at a different rate, so we tweak the averaging ratio and frequency
|
|
// of updating (the latter for efficiency too).
|
|
var audioLevel = 0.0;
|
|
var avgAudioLevel = 0.0;
|
|
|
|
var data = avatarData.sessionUUID === "" ? myData : ExtendedOverlay.get(avatarData.sessionUUID);
|
|
|
|
if (data) {
|
|
// we will do exponential moving average by taking some the last loudness and averaging
|
|
data.accumulatedLevel = AVERAGING_RATIO * (data.accumulatedLevel || 0) + (1 - AVERAGING_RATIO) * (avatarData.audioLoudness);
|
|
|
|
// add 1 to insure we don't go log() and hit -infinity. Math.log is
|
|
// natural log, so to get log base 2, just divide by ln(2).
|
|
audioLevel = scaleAudio(Math.log(data.accumulatedLevel + 1) / LOG2);
|
|
|
|
// decay avgAudioLevel
|
|
avgAudioLevel = Math.max((1 - AUDIO_PEAK_DECAY) * (data.avgAudioLevel || 0), audioLevel);
|
|
|
|
data.avgAudioLevel = avgAudioLevel;
|
|
data.audioLevel = audioLevel;
|
|
|
|
// now scale for the gain. Also, asked to boost the low end, so one simple way is
|
|
// to take sqrt of the value. Lets try that, see how it feels.
|
|
avgAudioLevel = Math.min(1.0, Math.sqrt(avgAudioLevel * (sessionGains[avatarData.sessionUUID] || 0.75)));
|
|
}
|
|
|
|
var param = {};
|
|
var level = [audioLevel, avgAudioLevel];
|
|
var userId = avatarData.sessionUUID;
|
|
param[userId] = level;
|
|
sendToQml({ method: 'updateAudioLevel', params: param });
|
|
}
|
|
|
|
var pingPong = true;
|
|
function updateOverlays() {
|
|
var eye = Camera.position;
|
|
|
|
var avatarData = AvatarList.getPalData().data;
|
|
|
|
avatarData.forEach(function (currentAvatarData) {
|
|
|
|
if (currentAvatarData.sessionUUID === "" || !avatarsOfInterest[currentAvatarData.sessionUUID]) {
|
|
return; // don't update ourself, or avatars we're not interested in
|
|
}
|
|
updateAudioLevel(currentAvatarData);
|
|
var overlay = ExtendedOverlay.get(currentAvatarData.sessionUUID);
|
|
if (!overlay) { // For now, we're treating this as a temporary loss, as from the personal space bubble. Add it back.
|
|
print('Adding non-PAL avatar node', currentAvatarData.sessionUUID);
|
|
overlay = addAvatarNode(currentAvatarData.sessionUUID);
|
|
}
|
|
|
|
var target = currentAvatarData.position;
|
|
var distance = Vec3.distance(target, eye);
|
|
var offset = currentAvatarData.palOrbOffset;
|
|
var diff = Vec3.subtract(target, eye); // get diff between target and eye (a vector pointing to the eye from avatar position)
|
|
|
|
// move a bit in front, towards the camera
|
|
target = Vec3.subtract(target, Vec3.multiply(Vec3.normalize(diff), offset));
|
|
|
|
// now bump it up a bit
|
|
target.y = target.y + offset;
|
|
|
|
overlay.ping = pingPong;
|
|
overlay.editOverlay({
|
|
color: color(ExtendedOverlay.isSelected(currentAvatarData.sessionUUID), overlay.hovering, overlay.audioLevel),
|
|
position: target,
|
|
dimensions: 0.032 * distance
|
|
});
|
|
if (overlay.model) {
|
|
overlay.model.ping = pingPong;
|
|
overlay.model.editOverlay({
|
|
position: target,
|
|
scale: 0.2 * distance, // constant apparent size
|
|
rotation: Camera.orientation
|
|
});
|
|
}
|
|
});
|
|
pingPong = !pingPong;
|
|
ExtendedOverlay.some(function (overlay) { // Remove any that weren't updated. (User is gone.)
|
|
if (overlay.ping === pingPong) {
|
|
overlay.deleteOverlay();
|
|
}
|
|
});
|
|
// We could re-populateNearbyUserList if anything added or removed, but not for now.
|
|
HighlightedEntity.updateOverlays();
|
|
}
|
|
function removeOverlays() {
|
|
selectedIds = [];
|
|
lastHoveringId = 0;
|
|
HighlightedEntity.clearOverlays();
|
|
ExtendedOverlay.some(function (overlay) {
|
|
overlay.deleteOverlay();
|
|
});
|
|
}
|
|
|
|
//
|
|
// Clicks.
|
|
//
|
|
function handleClick(pickRay) {
|
|
ExtendedOverlay.applyPickRay(pickRay, function (overlay) {
|
|
// Don't select directly. Tell qml, who will give us back a list of ids.
|
|
var message = {method: 'select', params: [[overlay.key], !overlay.selected, false]};
|
|
sendToQml(message);
|
|
return true;
|
|
});
|
|
}
|
|
function handleMouseEvent(mousePressEvent) { // handleClick if we get one.
|
|
if (!mousePressEvent.isLeftButton) {
|
|
return;
|
|
}
|
|
handleClick(Camera.computePickRay(mousePressEvent.x, mousePressEvent.y));
|
|
}
|
|
function handleMouseMove(pickRay) { // given the pickRay, just do the hover logic
|
|
ExtendedOverlay.applyPickRay(pickRay, function (overlay) {
|
|
overlay.hover(true);
|
|
}, function () {
|
|
ExtendedOverlay.unHover();
|
|
});
|
|
}
|
|
|
|
// handy global to keep track of which hand is the mouse (if any)
|
|
var currentHandPressed = 0;
|
|
var TRIGGER_CLICK_THRESHOLD = 0.85;
|
|
var TRIGGER_PRESS_THRESHOLD = 0.05;
|
|
|
|
function handleMouseMoveEvent(event) { // find out which overlay (if any) is over the mouse position
|
|
var pickRay;
|
|
if (HMD.active) {
|
|
if (currentHandPressed !== 0) {
|
|
pickRay = controllerComputePickRay(currentHandPressed);
|
|
} else {
|
|
// nothing should hover, so
|
|
ExtendedOverlay.unHover();
|
|
return;
|
|
}
|
|
} else {
|
|
pickRay = Camera.computePickRay(event.x, event.y);
|
|
}
|
|
handleMouseMove(pickRay);
|
|
}
|
|
function handleTriggerPressed(hand, value) {
|
|
// The idea is if you press one trigger, it is the one
|
|
// we will consider the mouse. Even if the other is pressed,
|
|
// we ignore it until this one is no longer pressed.
|
|
var isPressed = value > TRIGGER_PRESS_THRESHOLD;
|
|
if (currentHandPressed === 0) {
|
|
currentHandPressed = isPressed ? hand : 0;
|
|
return;
|
|
}
|
|
if (currentHandPressed === hand) {
|
|
currentHandPressed = isPressed ? hand : 0;
|
|
return;
|
|
}
|
|
// otherwise, the other hand is still triggered
|
|
// so do nothing.
|
|
}
|
|
|
|
// We get mouseMoveEvents from the handControllers, via handControllerPointer.
|
|
// But we don't get mousePressEvents.
|
|
var triggerMapping = Controller.newMapping(Script.resolvePath('') + '-click');
|
|
var triggerPressMapping = Controller.newMapping(Script.resolvePath('') + '-press');
|
|
function controllerComputePickRay(hand) {
|
|
var controllerPose = getControllerWorldLocation(hand, true);
|
|
if (controllerPose.valid) {
|
|
return { origin: controllerPose.position, direction: Quat.getUp(controllerPose.orientation) };
|
|
}
|
|
}
|
|
function makeClickHandler(hand) {
|
|
return function (clicked) {
|
|
if (clicked > TRIGGER_CLICK_THRESHOLD) {
|
|
var pickRay = controllerComputePickRay(hand);
|
|
handleClick(pickRay);
|
|
}
|
|
};
|
|
}
|
|
function makePressHandler(hand) {
|
|
return function (value) {
|
|
handleTriggerPressed(hand, value);
|
|
};
|
|
}
|
|
triggerMapping.from(controllerStandard.RTClick).peek().to(makeClickHandler(controllerStandard.RightHand));
|
|
triggerMapping.from(controllerStandard.LTClick).peek().to(makeClickHandler(controllerStandard.LeftHand));
|
|
triggerPressMapping.from(controllerStandard.RT).peek().to(makePressHandler(controllerStandard.RightHand));
|
|
triggerPressMapping.from(controllerStandard.LT).peek().to(makePressHandler(controllerStandard.LeftHand));
|
|
|
|
var ui;
|
|
// Most apps can have people toggle the tablet closed and open again, and the app should remain "open" even while
|
|
// the tablet is not shown. However, for the pal, we explicitly close the app and return the tablet to it's
|
|
// home screen (so that the avatar highlighting goes away).
|
|
function tabletVisibilityChanged() {
|
|
if (!ui.tablet.tabletShown && ui.isOpen) {
|
|
ui.close();
|
|
}
|
|
}
|
|
|
|
var UPDATE_INTERVAL_MS = 100;
|
|
var updateInterval;
|
|
function createUpdateInterval() {
|
|
return Script.setInterval(function () {
|
|
updateOverlays();
|
|
}, UPDATE_INTERVAL_MS);
|
|
}
|
|
|
|
var previousRequestsDomainListData = Users.requestsDomainListData;
|
|
function palOpened() {
|
|
ui.sendMessage({
|
|
method: 'changeConnectionsDotStatus',
|
|
shouldShowDot: shouldShowDot
|
|
});
|
|
|
|
previousRequestsDomainListData = Users.requestsDomainListData;
|
|
Users.requestsDomainListData = true;
|
|
|
|
ui.tablet.tabletShownChanged.connect(tabletVisibilityChanged);
|
|
updateInterval = createUpdateInterval();
|
|
Controller.mousePressEvent.connect(handleMouseEvent);
|
|
Controller.mouseMoveEvent.connect(handleMouseMoveEvent);
|
|
Users.usernameFromIDReply.connect(usernameFromIDReply);
|
|
triggerMapping.enable();
|
|
triggerPressMapping.enable();
|
|
populateNearbyUserList();
|
|
}
|
|
|
|
//
|
|
// Message from other scripts, such as edit.js
|
|
//
|
|
var CHANNEL = 'com.highfidelity.pal';
|
|
function receiveMessage(channel, messageString, senderID) {
|
|
if ((channel !== CHANNEL) || (senderID !== MyAvatar.sessionUUID)) {
|
|
return;
|
|
}
|
|
var message = JSON.parse(messageString);
|
|
switch (message.method) {
|
|
case 'select':
|
|
if (!ui.isOpen) {
|
|
ui.open();
|
|
Script.setTimeout(function () { sendToQml(message); }, 1000);
|
|
} else {
|
|
sendToQml(message); // Accepts objects, not just strings.
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
var AVERAGING_RATIO = 0.05;
|
|
var LOUDNESS_FLOOR = 11.0;
|
|
var LOUDNESS_SCALE = 2.8 / 5.0;
|
|
var LOG2 = Math.log(2.0);
|
|
var AUDIO_PEAK_DECAY = 0.02;
|
|
var myData = {}; // we're not includied in ExtendedOverlay.get.
|
|
|
|
function scaleAudio(val) {
|
|
var audioLevel = 0.0;
|
|
if (val <= LOUDNESS_FLOOR) {
|
|
audioLevel = val / LOUDNESS_FLOOR * LOUDNESS_SCALE;
|
|
} else {
|
|
audioLevel = (val - (LOUDNESS_FLOOR - 1)) * LOUDNESS_SCALE;
|
|
}
|
|
if (audioLevel > 1.0) {
|
|
audioLevel = 1;
|
|
}
|
|
return audioLevel;
|
|
}
|
|
|
|
function avatarDisconnected(nodeID) {
|
|
// remove from the pal list
|
|
sendToQml({method: 'avatarDisconnected', params: [nodeID]});
|
|
}
|
|
|
|
function clearLocalQMLDataAndClosePAL() {
|
|
sendToQml({ method: 'clearLocalQMLData' });
|
|
if (ui.isOpen) {
|
|
ui.close();
|
|
}
|
|
}
|
|
|
|
function avatarAdded(avatarID) {
|
|
sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarAdded'] });
|
|
}
|
|
|
|
function avatarRemoved(avatarID) {
|
|
sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarRemoved'] });
|
|
}
|
|
|
|
function avatarSessionChanged(avatarID) {
|
|
sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarSessionChanged'] });
|
|
}
|
|
|
|
function notificationDataProcessPage(data) {
|
|
return data.data.users;
|
|
}
|
|
|
|
var shouldShowDot = false;
|
|
var pingPong = false;
|
|
var storedOnlineUsers = {};
|
|
function notificationPollCallback(connectionsArray) {
|
|
//
|
|
// START logic for handling online/offline user changes
|
|
//
|
|
pingPong = !pingPong;
|
|
var newOnlineUsers = 0;
|
|
var message;
|
|
|
|
connectionsArray.forEach(function (user) {
|
|
var stored = storedOnlineUsers[user.username];
|
|
var storedOrNew = stored || user;
|
|
storedOrNew.pingPong = pingPong;
|
|
if (stored) {
|
|
return;
|
|
}
|
|
|
|
newOnlineUsers++;
|
|
storedOnlineUsers[user.username] = user;
|
|
|
|
if (!ui.isOpen && ui.notificationInitialCallbackMade[0]) {
|
|
message = user.username + " is available in " +
|
|
user.location.root.name + ". Open PEOPLE to join them.";
|
|
ui.notificationDisplayBanner(message);
|
|
}
|
|
});
|
|
var key;
|
|
for (key in storedOnlineUsers) {
|
|
if (storedOnlineUsers[key].pingPong !== pingPong) {
|
|
delete storedOnlineUsers[key];
|
|
}
|
|
}
|
|
shouldShowDot = newOnlineUsers > 0 || (Object.keys(storedOnlineUsers).length > 0 && shouldShowDot);
|
|
//
|
|
// END logic for handling online/offline user changes
|
|
//
|
|
|
|
if (!ui.isOpen) {
|
|
ui.messagesWaiting(shouldShowDot);
|
|
ui.sendMessage({
|
|
method: 'changeConnectionsDotStatus',
|
|
shouldShowDot: shouldShowDot
|
|
});
|
|
|
|
if (newOnlineUsers > 0 && !ui.notificationInitialCallbackMade[0]) {
|
|
message = newOnlineUsers + " of your connections " +
|
|
(newOnlineUsers === 1 ? "is" : "are") + " available online. Open PEOPLE to join them.";
|
|
ui.notificationDisplayBanner(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isReturnedDataEmpty(data) {
|
|
var usersArray = data.data.users;
|
|
return usersArray.length === 0;
|
|
}
|
|
|
|
function startup() {
|
|
ui = new AppUi({
|
|
buttonName: "PEOPLE",
|
|
sortOrder: 7,
|
|
home: "hifi/Pal.qml",
|
|
onOpened: palOpened,
|
|
onClosed: off,
|
|
onMessage: fromQml,
|
|
notificationPollEndpoint: ["/api/v1/users?filter=connections&status=online&per_page=10"],
|
|
notificationPollTimeoutMs: [60000],
|
|
notificationDataProcessPage: [notificationDataProcessPage],
|
|
notificationPollCallback: [notificationPollCallback],
|
|
notificationPollStopPaginatingConditionMet: [isReturnedDataEmpty],
|
|
notificationPollCaresAboutSince: [false]
|
|
});
|
|
Window.domainChanged.connect(clearLocalQMLDataAndClosePAL);
|
|
Window.domainConnectionRefused.connect(clearLocalQMLDataAndClosePAL);
|
|
Messages.subscribe(CHANNEL);
|
|
Messages.messageReceived.connect(receiveMessage);
|
|
Users.avatarDisconnected.connect(avatarDisconnected);
|
|
AvatarList.avatarAddedEvent.connect(avatarAdded);
|
|
AvatarList.avatarRemovedEvent.connect(avatarRemoved);
|
|
AvatarList.avatarSessionChangedEvent.connect(avatarSessionChanged);
|
|
}
|
|
startup();
|
|
|
|
function off() {
|
|
if (ui.isOpen) { // i.e., only when connected
|
|
if (updateInterval) {
|
|
Script.clearInterval(updateInterval);
|
|
}
|
|
Controller.mousePressEvent.disconnect(handleMouseEvent);
|
|
Controller.mouseMoveEvent.disconnect(handleMouseMoveEvent);
|
|
ui.tablet.tabletShownChanged.disconnect(tabletVisibilityChanged);
|
|
Users.usernameFromIDReply.disconnect(usernameFromIDReply);
|
|
triggerMapping.disable();
|
|
triggerPressMapping.disable();
|
|
}
|
|
|
|
removeOverlays();
|
|
Users.requestsDomainListData = previousRequestsDomainListData;
|
|
}
|
|
|
|
function shutdown() {
|
|
Window.domainChanged.disconnect(clearLocalQMLDataAndClosePAL);
|
|
Window.domainConnectionRefused.disconnect(clearLocalQMLDataAndClosePAL);
|
|
Messages.subscribe(CHANNEL);
|
|
Messages.messageReceived.disconnect(receiveMessage);
|
|
Users.avatarDisconnected.disconnect(avatarDisconnected);
|
|
AvatarList.avatarAddedEvent.disconnect(avatarAdded);
|
|
AvatarList.avatarRemovedEvent.disconnect(avatarRemoved);
|
|
AvatarList.avatarSessionChangedEvent.disconnect(avatarSessionChanged);
|
|
off();
|
|
}
|
|
Script.scriptEnding.connect(shutdown);
|
|
|
|
}()); // END LOCAL_SCOPE
|