From 4335bbe4fe7384efedca288c257ea50882c1034d Mon Sep 17 00:00:00 2001 From: Seth Alves Date: Tue, 24 Jan 2017 18:48:11 -0800 Subject: [PATCH] provide a way to switch mostly back to HUD-based ui --- scripts/defaultScriptsHUD.js | 131 ++ scripts/system/bubbleHUD.js | 195 +++ scripts/system/gotoHUD.js | 46 + scripts/system/helpHUD.js | 41 + scripts/system/hmdHUD.js | 79 ++ scripts/system/marketplaces/marketplaceHUD.js | 131 ++ .../system/marketplaces/marketplacesHUD.js | 233 ++++ scripts/system/muteHUD.js | 51 + scripts/system/palHUD.js | 658 +++++++++ scripts/system/snapshotHUD.js | 226 +++ scripts/system/usersHUD.js | 1237 +++++++++++++++++ 11 files changed, 3028 insertions(+) create mode 100644 scripts/defaultScriptsHUD.js create mode 100644 scripts/system/bubbleHUD.js create mode 100644 scripts/system/gotoHUD.js create mode 100644 scripts/system/helpHUD.js create mode 100644 scripts/system/hmdHUD.js create mode 100644 scripts/system/marketplaces/marketplaceHUD.js create mode 100644 scripts/system/marketplaces/marketplacesHUD.js create mode 100644 scripts/system/muteHUD.js create mode 100644 scripts/system/palHUD.js create mode 100644 scripts/system/snapshotHUD.js create mode 100644 scripts/system/usersHUD.js diff --git a/scripts/defaultScriptsHUD.js b/scripts/defaultScriptsHUD.js new file mode 100644 index 0000000000..098a74ffd0 --- /dev/null +++ b/scripts/defaultScriptsHUD.js @@ -0,0 +1,131 @@ +"use strict"; +/* jslint vars: true, plusplus: true */ + +// +// defaultScripts.js +// examples +// +// Copyright 2014 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +var DEFAULT_SCRIPTS = [ + "system/progress.js", + "system/away.js", + "system/usersHUD.js", + "system/muteHUD.js", + "system/gotoHUD.js", + "system/hmdHUD.js", + "system/marketplaces/marketplacesHUD.js", + "system/edit.js", + "system/palHUD.js", //"system/mod.js", // older UX, if you prefer + "system/selectAudioDevice.js", + "system/notifications.js", + "system/controllers/controllerDisplayManager.js", + "system/controllers/handControllerGrab.js", + "system/controllers/handControllerPointer.js", + "system/controllers/squeezeHands.js", + "system/controllers/grab.js", + "system/controllers/teleport.js", + "system/controllers/toggleAdvancedMovementForHandControllers.js", + "system/dialTone.js", + "system/firstPersonHMD.js", + "system/snapshotHUD.js", + "system/helpHUD.js", + "system/bubbleHUD.js" +]; + +// add a menu item for debugging +var MENU_CATEGORY = "Developer"; +var MENU_ITEM = "Debug defaultScripts.js"; + +var SETTINGS_KEY = '_debugDefaultScriptsIsChecked'; +var previousSetting = Settings.getValue(SETTINGS_KEY); + +if (previousSetting === '' || previousSetting === false || previousSetting === 'false') { + previousSetting = false; +} + +if (previousSetting === true || previousSetting === 'true') { + previousSetting = true; +} + + + + +if (Menu.menuExists(MENU_CATEGORY) && !Menu.menuItemExists(MENU_CATEGORY, MENU_ITEM)) { + Menu.addMenuItem({ + menuName: MENU_CATEGORY, + menuItemName: MENU_ITEM, + isCheckable: true, + isChecked: previousSetting, + grouping: "Advanced" + }); +} + +function runDefaultsTogether() { + for (var j in DEFAULT_SCRIPTS) { + Script.include(DEFAULT_SCRIPTS[j]); + } +} + +function runDefaultsSeparately() { + for (var i in DEFAULT_SCRIPTS) { + Script.load(DEFAULT_SCRIPTS[i]); + } +} +// start all scripts +if (Menu.isOptionChecked(MENU_ITEM)) { + // we're debugging individual default scripts + // so we load each into its own ScriptEngine instance + debuggingDefaultScripts = true; + runDefaultsSeparately(); +} else { + // include all default scripts into this ScriptEngine + runDefaultsTogether(); +} + +function menuItemEvent(menuItem) { + if (menuItem == MENU_ITEM) { + + isChecked = Menu.isOptionChecked(MENU_ITEM); + if (isChecked === true) { + Settings.setValue(SETTINGS_KEY, true); + } else if (isChecked === false) { + Settings.setValue(SETTINGS_KEY, false); + } + Window.alert('You must reload all scripts for this to take effect.') + } + + +} + + + +function stopLoadedScripts() { + // remove debug script loads + var runningScripts = ScriptDiscoveryService.getRunning(); + for (var i in runningScripts) { + var scriptName = runningScripts[i].name; + for (var j in DEFAULT_SCRIPTS) { + if (DEFAULT_SCRIPTS[j].slice(-scriptName.length) === scriptName) { + ScriptDiscoveryService.stopScript(runningScripts[i].url); + } + } + } +} + +function removeMenuItem() { + if (!Menu.isOptionChecked(MENU_ITEM)) { + Menu.removeMenuItem(MENU_CATEGORY, MENU_ITEM); + } +} + +Script.scriptEnding.connect(function() { + stopLoadedScripts(); + removeMenuItem(); +}); + +Menu.menuItemEvent.connect(menuItemEvent); diff --git a/scripts/system/bubbleHUD.js b/scripts/system/bubbleHUD.js new file mode 100644 index 0000000000..2f7286872e --- /dev/null +++ b/scripts/system/bubbleHUD.js @@ -0,0 +1,195 @@ +"use strict"; + +// +// bubble.js +// scripts/system/ +// +// Created by Brad Hefta-Gaub on 11/18/2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// +/* global Toolbars, Script, Users, Overlays, AvatarList, Controller, Camera, getControllerWorldLocation */ + + +(function () { // BEGIN LOCAL_SCOPE + + // grab the toolbar + var toolbar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); + // Used for animating and disappearing the bubble + var bubbleOverlayTimestamp; + // Used for flashing the HUD button upon activation + var bubbleButtonFlashState = false; + // Used for flashing the HUD button upon activation + var bubbleButtonTimestamp; + // Affects bubble height + const BUBBLE_HEIGHT_SCALE = 0.15; + // The bubble model itself + var bubbleOverlay = Overlays.addOverlay("model", { + url: Script.resolvePath("assets/models/Bubble-v14.fbx"), // If you'd like to change the model, modify this line (and the dimensions below) + dimensions: { x: 1.0, y: 0.75, z: 1.0 }, + position: { x: MyAvatar.position.x, y: -MyAvatar.scale * 2 + MyAvatar.position.y + MyAvatar.scale * BUBBLE_HEIGHT_SCALE, z: MyAvatar.position.z }, + rotation: Quat.fromPitchYawRollDegrees(MyAvatar.bodyPitch, 0, MyAvatar.bodyRoll), + scale: { x: 2, y: MyAvatar.scale * 0.5 + 0.5, z: 2 }, + visible: false, + ignoreRayIntersection: true + }); + // The bubble activation sound + var bubbleActivateSound = SoundCache.getSound(Script.resolvePath("assets/sounds/bubble.wav")); + // Is the update() function connected? + var updateConnected = false; + + const BUBBLE_VISIBLE_DURATION_MS = 3000; + const BUBBLE_RAISE_ANIMATION_DURATION_MS = 750; + const BUBBLE_HUD_ICON_FLASH_INTERVAL_MS = 500; + + var ASSETS_PATH = Script.resolvePath("assets"); + var TOOLS_PATH = Script.resolvePath("assets/images/tools/"); + + function buttonImageURL() { + return TOOLS_PATH + 'bubble.svg'; + } + + // Hides the bubble model overlay and resets the button flash state + function hideOverlays() { + Overlays.editOverlay(bubbleOverlay, { + visible: false + }); + bubbleButtonFlashState = false; + } + + // Make the bubble overlay visible, set its position, and play the sound + function createOverlays() { + Audio.playSound(bubbleActivateSound, { + position: { x: MyAvatar.position.x, y: MyAvatar.position.y, z: MyAvatar.position.z }, + localOnly: true, + volume: 0.2 + }); + hideOverlays(); + if (updateConnected === true) { + updateConnected = false; + Script.update.disconnect(update); + } + + Overlays.editOverlay(bubbleOverlay, { + position: { x: MyAvatar.position.x, y: -MyAvatar.scale * 2 + MyAvatar.position.y + MyAvatar.scale * BUBBLE_HEIGHT_SCALE, z: MyAvatar.position.z }, + rotation: Quat.fromPitchYawRollDegrees(MyAvatar.bodyPitch, 0, MyAvatar.bodyRoll), + scale: { x: 2, y: MyAvatar.scale * 0.5 + 0.5, z: 2 }, + visible: true + }); + bubbleOverlayTimestamp = Date.now(); + bubbleButtonTimestamp = bubbleOverlayTimestamp; + Script.update.connect(update); + updateConnected = true; + } + + // Called from the C++ scripting interface to show the bubble overlay + function enteredIgnoreRadius() { + createOverlays(); + } + + // Used to set the state of the bubble HUD button + function writeButtonProperties(parameter) { + button.writeProperty('buttonState', parameter ? 0 : 1); + button.writeProperty('defaultState', parameter ? 0 : 1); + button.writeProperty('hoverState', parameter ? 2 : 3); + } + + // The bubble script's update function + update = function () { + var timestamp = Date.now(); + var delay = (timestamp - bubbleOverlayTimestamp); + var overlayAlpha = 1.0 - (delay / BUBBLE_VISIBLE_DURATION_MS); + if (overlayAlpha > 0) { + // Flash button + if ((timestamp - bubbleButtonTimestamp) >= BUBBLE_VISIBLE_DURATION_MS) { + writeButtonProperties(bubbleButtonFlashState); + bubbleButtonTimestamp = timestamp; + bubbleButtonFlashState = !bubbleButtonFlashState; + } + + if (delay < BUBBLE_RAISE_ANIMATION_DURATION_MS) { + Overlays.editOverlay(bubbleOverlay, { + // Quickly raise the bubble from the ground up + position: { + x: MyAvatar.position.x, + y: (-((BUBBLE_RAISE_ANIMATION_DURATION_MS - delay) / BUBBLE_RAISE_ANIMATION_DURATION_MS)) * MyAvatar.scale * 2 + MyAvatar.position.y + MyAvatar.scale * BUBBLE_HEIGHT_SCALE, + z: MyAvatar.position.z + }, + rotation: Quat.fromPitchYawRollDegrees(MyAvatar.bodyPitch, 0, MyAvatar.bodyRoll), + scale: { + x: 2, + y: ((1 - ((BUBBLE_RAISE_ANIMATION_DURATION_MS - delay) / BUBBLE_RAISE_ANIMATION_DURATION_MS)) * MyAvatar.scale * 0.5 + 0.5), + z: 2 + } + }); + } else { + // Keep the bubble in place for a couple seconds + Overlays.editOverlay(bubbleOverlay, { + position: { + x: MyAvatar.position.x, + y: MyAvatar.position.y + MyAvatar.scale * BUBBLE_HEIGHT_SCALE, + z: MyAvatar.position.z + }, + rotation: Quat.fromPitchYawRollDegrees(MyAvatar.bodyPitch, 0, MyAvatar.bodyRoll), + scale: { + x: 2, + y: MyAvatar.scale * 0.5 + 0.5, + z: 2 + } + }); + } + } else { + hideOverlays(); + if (updateConnected === true) { + Script.update.disconnect(update); + updateConnected = false; + } + var bubbleActive = Users.getIgnoreRadiusEnabled(); + writeButtonProperties(bubbleActive); + } + }; + + // When the space bubble is toggled... + function onBubbleToggled() { + var bubbleActive = Users.getIgnoreRadiusEnabled(); + writeButtonProperties(bubbleActive); + if (bubbleActive) { + createOverlays(); + } else { + hideOverlays(); + if (updateConnected === true) { + Script.update.disconnect(update); + updateConnected = false; + } + } + } + + // Setup the bubble button and add it to the toolbar + var button = toolbar.addButton({ + objectName: 'bubble', + imageURL: buttonImageURL(), + visible: true, + alpha: 0.9 + }); + onBubbleToggled(); + + button.clicked.connect(Users.toggleIgnoreRadius); + Users.ignoreRadiusEnabledChanged.connect(onBubbleToggled); + Users.enteredIgnoreRadius.connect(enteredIgnoreRadius); + + // Cleanup the toolbar button and overlays when script is stopped + Script.scriptEnding.connect(function () { + toolbar.removeButton('bubble'); + button.clicked.disconnect(Users.toggleIgnoreRadius); + Users.ignoreRadiusEnabledChanged.disconnect(onBubbleToggled); + Users.enteredIgnoreRadius.disconnect(enteredIgnoreRadius); + Overlays.deleteOverlay(bubbleOverlay); + bubbleButtonFlashState = false; + if (updateConnected === true) { + Script.update.disconnect(update); + } + }); + +}()); // END LOCAL_SCOPE diff --git a/scripts/system/gotoHUD.js b/scripts/system/gotoHUD.js new file mode 100644 index 0000000000..9116142293 --- /dev/null +++ b/scripts/system/gotoHUD.js @@ -0,0 +1,46 @@ +"use strict"; + +// +// goto.js +// scripts/system/ +// +// Created by Howard Stearns on 2 Jun 2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +(function() { // BEGIN LOCAL_SCOPE + +var toolBar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); + + +var button = toolBar.addButton({ + objectName: "goto", + imageURL: Script.resolvePath("assets/images/tools/directory.svg"), + visible: true, + buttonState: 1, + defaultState: 1, + hoverState: 3, + alpha: 0.9, +}); + +function onAddressBarShown(visible) { + button.writeProperty('buttonState', visible ? 0 : 1); + button.writeProperty('defaultState', visible ? 0 : 1); + button.writeProperty('hoverState', visible ? 2 : 3); +} +function onClicked(){ + DialogsManager.toggleAddressBar(); +} +button.clicked.connect(onClicked); +DialogsManager.addressBarShown.connect(onAddressBarShown); + +Script.scriptEnding.connect(function () { + toolBar.removeButton("goto"); + button.clicked.disconnect(onClicked); + DialogsManager.addressBarShown.disconnect(onAddressBarShown); +}); + +}()); // END LOCAL_SCOPE diff --git a/scripts/system/helpHUD.js b/scripts/system/helpHUD.js new file mode 100644 index 0000000000..e79ed0444c --- /dev/null +++ b/scripts/system/helpHUD.js @@ -0,0 +1,41 @@ +"use strict"; + +// +// help.js +// scripts/system/ +// +// Created by Howard Stearns on 2 Nov 2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +(function() { // BEGIN LOCAL_SCOPE + + var toolBar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); + var buttonName = "help"; // matching location reserved in Desktop.qml + var button = toolBar.addButton({ + objectName: buttonName, + imageURL: Script.resolvePath("assets/images/tools/help.svg"), + visible: true, + hoverState: 2, + defaultState: 1, + buttonState: 1, + alpha: 0.9 + }); + + // TODO: make button state reflect whether the window is opened or closed (independently from us). + + function onClicked(){ + Menu.triggerOption('Help...') + } + + button.clicked.connect(onClicked); + + Script.scriptEnding.connect(function () { + toolBar.removeButton(buttonName); + button.clicked.disconnect(onClicked); + }); + +}()); // END LOCAL_SCOPE diff --git a/scripts/system/hmdHUD.js b/scripts/system/hmdHUD.js new file mode 100644 index 0000000000..5dd06de8eb --- /dev/null +++ b/scripts/system/hmdHUD.js @@ -0,0 +1,79 @@ +"use strict"; + +// +// hmd.js +// scripts/system/ +// +// Created by Howard Stearns on 2 Jun 2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +(function() { // BEGIN LOCAL_SCOPE + +var headset; // The preferred headset. Default to the first one found in the following list. +var displayMenuName = "Display"; +var desktopMenuItemName = "Desktop"; +['OpenVR (Vive)', 'Oculus Rift'].forEach(function (name) { + if (!headset && Menu.menuItemExists(displayMenuName, name)) { + headset = name; + } +}); + +var controllerDisplay = false; +function updateControllerDisplay() { + if (HMD.active && Menu.isOptionChecked("Third Person")) { + if (!controllerDisplay) { + HMD.requestShowHandControllers(); + controllerDisplay = true; + } + } else if (controllerDisplay) { + HMD.requestHideHandControllers(); + controllerDisplay = false; + } +} + +var toolBar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); +var button; +// Independent and Entity mode make people sick. Third Person and Mirror have traps that we need to work through. +// Disable them in hmd. +var desktopOnlyViews = ['Mirror', 'Independent Mode', 'Entity Mode']; +function onHmdChanged(isHmd) { + button.writeProperty('buttonState', isHmd ? 0 : 1); + button.writeProperty('defaultState', isHmd ? 0 : 1); + button.writeProperty('hoverState', isHmd ? 2 : 3); + desktopOnlyViews.forEach(function (view) { + Menu.setMenuEnabled("View>" + view, !isHmd); + }); + updateControllerDisplay(); +} +function onClicked(){ + var isDesktop = Menu.isOptionChecked(desktopMenuItemName); + Menu.setIsOptionChecked(isDesktop ? headset : desktopMenuItemName, true); +} +if (headset) { + button = toolBar.addButton({ + objectName: "hmdToggle", + imageURL: Script.resolvePath("assets/images/tools/switch.svg"), + visible: true, + hoverState: 2, + defaultState: 0, + alpha: 0.9 + }); + onHmdChanged(HMD.active); + + button.clicked.connect(onClicked); + HMD.displayModeChanged.connect(onHmdChanged); + Camera.modeUpdated.connect(updateControllerDisplay); + + Script.scriptEnding.connect(function () { + toolBar.removeButton("hmdToggle"); + button.clicked.disconnect(onClicked); + HMD.displayModeChanged.disconnect(onHmdChanged); + Camera.modeUpdated.disconnect(updateControllerDisplay); + }); +} + +}()); // END LOCAL_SCOPE diff --git a/scripts/system/marketplaces/marketplaceHUD.js b/scripts/system/marketplaces/marketplaceHUD.js new file mode 100644 index 0000000000..894dae7eac --- /dev/null +++ b/scripts/system/marketplaces/marketplaceHUD.js @@ -0,0 +1,131 @@ +// +// marketplace.js +// +// Created by Eric Levin on 8 Jan 2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +(function() { // BEGIN LOCAL_SCOPE + +/* global WebTablet */ +Script.include("../libraries/WebTablet.js"); + +var toolIconUrl = Script.resolvePath("../assets/images/tools/"); + +var MARKETPLACE_URL = "https://metaverse.highfidelity.com/marketplace"; +var marketplaceWindow = new OverlayWebWindow({ + title: "Marketplace", + source: "about:blank", + width: 900, + height: 700, + visible: false +}); + +var toolHeight = 50; +var toolWidth = 50; +var TOOLBAR_MARGIN_Y = 0; +var marketplaceVisible = false; +var marketplaceWebTablet; + +// We persist clientOnly data in the .ini file, and reconsistitute it on restart. +// To keep things consistent, we pickle the tablet data in Settings, and kill any existing such on restart and domain change. +var persistenceKey = "io.highfidelity.lastDomainTablet"; + +function shouldShowWebTablet() { + var rightPose = Controller.getPoseValue(Controller.Standard.RightHand); + var leftPose = Controller.getPoseValue(Controller.Standard.LeftHand); + var hasHydra = !!Controller.Hardware.Hydra; + return HMD.active && (leftPose.valid || rightPose.valid || hasHydra); +} + +function showMarketplace(marketplaceID) { + if (shouldShowWebTablet()) { + updateButtonState(true); + marketplaceWebTablet = new WebTablet("https://metaverse.highfidelity.com/marketplace", null, null, true); + Settings.setValue(persistenceKey, marketplaceWebTablet.pickle()); + } else { + var url = MARKETPLACE_URL; + if (marketplaceID) { + url = url + "/items/" + marketplaceID; + } + marketplaceWindow.setURL(url); + marketplaceWindow.setVisible(true); + } + + marketplaceVisible = true; + UserActivityLogger.openedMarketplace(); +} + +function hideTablet(tablet) { + if (!tablet) { + return; + } + updateButtonState(false); + tablet.destroy(); + marketplaceWebTablet = null; + Settings.setValue(persistenceKey, ""); +} +function clearOldTablet() { // If there was a tablet from previous domain or session, kill it and let it be recreated + var tablet = WebTablet.unpickle(Settings.getValue(persistenceKey, "")); + hideTablet(tablet); +} +function hideMarketplace() { + if (marketplaceWindow.visible) { + marketplaceWindow.setVisible(false); + marketplaceWindow.setURL("about:blank"); + } else if (marketplaceWebTablet) { + hideTablet(marketplaceWebTablet); + } + marketplaceVisible = false; +} + +function toggleMarketplace() { + if (marketplaceVisible) { + hideMarketplace(); + } else { + showMarketplace(); + } +} + +var toolBar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); + +var browseExamplesButton = toolBar.addButton({ + imageURL: toolIconUrl + "market.svg", + objectName: "marketplace", + buttonState: 1, + defaultState: 1, + hoverState: 3, + alpha: 0.9 +}); + +function updateButtonState(visible) { + browseExamplesButton.writeProperty('buttonState', visible ? 0 : 1); + browseExamplesButton.writeProperty('defaultState', visible ? 0 : 1); + browseExamplesButton.writeProperty('hoverState', visible ? 2 : 3); +} +function onMarketplaceWindowVisibilityChanged() { + updateButtonState(marketplaceWindow.visible); + marketplaceVisible = marketplaceWindow.visible; +} + +function onClick() { + toggleMarketplace(); +} + +browseExamplesButton.clicked.connect(onClick); +marketplaceWindow.visibleChanged.connect(onMarketplaceWindowVisibilityChanged); + +clearOldTablet(); // Run once at startup, in case there's anything laying around from a crash. +// We could also optionally do something like Window.domainChanged.connect(function () {Script.setTimeout(clearOldTablet, 2000)}), +// but the HUD version stays around, so lets do the same. + +Script.scriptEnding.connect(function () { + toolBar.removeButton("marketplace"); + browseExamplesButton.clicked.disconnect(onClick); + marketplaceWindow.visibleChanged.disconnect(onMarketplaceWindowVisibilityChanged); +}); + +}()); // END LOCAL_SCOPE diff --git a/scripts/system/marketplaces/marketplacesHUD.js b/scripts/system/marketplaces/marketplacesHUD.js new file mode 100644 index 0000000000..d5530e7db2 --- /dev/null +++ b/scripts/system/marketplaces/marketplacesHUD.js @@ -0,0 +1,233 @@ +// +// marketplaces.js +// +// Created by Eric Levin on 8 Jan 2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +(function() { // BEGIN LOCAL_SCOPE + +/* global WebTablet */ +Script.include("../libraries/WebTablet.js"); + +var toolIconUrl = Script.resolvePath("../assets/images/tools/"); + +var MARKETPLACE_URL = "https://metaverse.highfidelity.com/marketplace"; +var MARKETPLACE_URL_INITIAL = MARKETPLACE_URL + "?"; // Append "?" to signal injected script that it's the initial page. +var MARKETPLACES_URL = Script.resolvePath("../html/marketplaces.html"); +var MARKETPLACES_INJECT_SCRIPT_URL = Script.resolvePath("../html/js/marketplacesInject.js"); + +// Event bridge messages. +var CLARA_IO_DOWNLOAD = "CLARA.IO DOWNLOAD"; +var CLARA_IO_STATUS = "CLARA.IO STATUS"; +var CLARA_IO_CANCEL_DOWNLOAD = "CLARA.IO CANCEL DOWNLOAD"; +var CLARA_IO_CANCELLED_DOWNLOAD = "CLARA.IO CANCELLED DOWNLOAD"; +var GOTO_DIRECTORY = "GOTO_DIRECTORY"; +var QUERY_CAN_WRITE_ASSETS = "QUERY_CAN_WRITE_ASSETS"; +var CAN_WRITE_ASSETS = "CAN_WRITE_ASSETS"; +var WARN_USER_NO_PERMISSIONS = "WARN_USER_NO_PERMISSIONS"; + +var CLARA_DOWNLOAD_TITLE = "Preparing Download"; +var messageBox = null; +var isDownloadBeingCancelled = false; + +var CANCEL_BUTTON = 4194304; // QMessageBox::Cancel +var NO_BUTTON = 0; // QMessageBox::NoButton + +var NO_PERMISSIONS_ERROR_MESSAGE = "Cannot download model because you can't write to \nthe domain's Asset Server."; + +var marketplaceWindow = new OverlayWebWindow({ + title: "Marketplace", + source: "about:blank", + width: 900, + height: 700, + visible: false +}); +marketplaceWindow.setScriptURL(MARKETPLACES_INJECT_SCRIPT_URL); + +function onWebEventReceived(message) { + if (message === GOTO_DIRECTORY) { + var url = MARKETPLACES_URL; + if (marketplaceWindow.visible) { + marketplaceWindow.setURL(url); + } + if (marketplaceWebTablet) { + marketplaceWebTablet.setURL(url); + } + return; + } + if (message === QUERY_CAN_WRITE_ASSETS) { + var canWriteAssets = CAN_WRITE_ASSETS + " " + Entities.canWriteAssets(); + if (marketplaceWindow.visible) { + marketplaceWindow.emitScriptEvent(canWriteAssets); + } + if (marketplaceWebTablet) { + marketplaceWebTablet.getOverlayObject().emitScriptEvent(canWriteAssets); + } + return; + } + if (message === WARN_USER_NO_PERMISSIONS) { + Window.alert(NO_PERMISSIONS_ERROR_MESSAGE); + return; + } + + if (message.slice(0, CLARA_IO_STATUS.length) === CLARA_IO_STATUS) { + if (isDownloadBeingCancelled) { + return; + } + + var text = message.slice(CLARA_IO_STATUS.length); + if (messageBox === null) { + messageBox = Window.openMessageBox(CLARA_DOWNLOAD_TITLE, text, CANCEL_BUTTON, NO_BUTTON); + } else { + Window.updateMessageBox(messageBox, CLARA_DOWNLOAD_TITLE, text, CANCEL_BUTTON, NO_BUTTON); + } + return; + } + + if (message.slice(0, CLARA_IO_DOWNLOAD.length) === CLARA_IO_DOWNLOAD) { + if (messageBox !== null) { + Window.closeMessageBox(messageBox); + messageBox = null; + } + return; + } + + if (message === CLARA_IO_CANCELLED_DOWNLOAD) { + isDownloadBeingCancelled = false; + } +} + +marketplaceWindow.webEventReceived.connect(onWebEventReceived); + +function onMessageBoxClosed(id, button) { + if (id === messageBox && button === CANCEL_BUTTON) { + isDownloadBeingCancelled = true; + messageBox = null; + marketplaceWindow.emitScriptEvent(CLARA_IO_CANCEL_DOWNLOAD); + } +} + +Window.messageBoxClosed.connect(onMessageBoxClosed); + +var toolHeight = 50; +var toolWidth = 50; +var TOOLBAR_MARGIN_Y = 0; +var marketplaceVisible = false; +var marketplaceWebTablet; + +// We persist clientOnly data in the .ini file, and reconstitute it on restart. +// To keep things consistent, we pickle the tablet data in Settings, and kill any existing such on restart and domain change. +var persistenceKey = "io.highfidelity.lastDomainTablet"; + +function shouldShowWebTablet() { + var rightPose = Controller.getPoseValue(Controller.Standard.RightHand); + var leftPose = Controller.getPoseValue(Controller.Standard.LeftHand); + var hasHydra = !!Controller.Hardware.Hydra; + return HMD.active && (leftPose.valid || rightPose.valid || hasHydra); +} + +function showMarketplace() { + if (shouldShowWebTablet()) { + updateButtonState(true); + marketplaceWebTablet = new WebTablet(MARKETPLACE_URL_INITIAL, null, null, true); + Settings.setValue(persistenceKey, marketplaceWebTablet.pickle()); + marketplaceWebTablet.setScriptURL(MARKETPLACES_INJECT_SCRIPT_URL); + marketplaceWebTablet.getOverlayObject().webEventReceived.connect(onWebEventReceived); + } else { + marketplaceWindow.setURL(MARKETPLACE_URL_INITIAL); + marketplaceWindow.setVisible(true); + } + + marketplaceVisible = true; + UserActivityLogger.openedMarketplace(); +} + +function hideTablet(tablet) { + if (!tablet) { + return; + } + updateButtonState(false); + tablet.destroy(); + marketplaceWebTablet = null; + Settings.setValue(persistenceKey, ""); +} +function clearOldTablet() { // If there was a tablet from previous domain or session, kill it and let it be recreated + var tablet = WebTablet.unpickle(Settings.getValue(persistenceKey, "")); + hideTablet(tablet); +} +function hideMarketplace() { + if (marketplaceWindow.visible) { + marketplaceWindow.setVisible(false); + marketplaceWindow.setURL("about:blank"); + } else if (marketplaceWebTablet) { + hideTablet(marketplaceWebTablet); + } + marketplaceVisible = false; +} +marketplaceWindow.closed.connect(function () { + marketplaceWindow.setURL("about:blank"); +}); + +function toggleMarketplace() { + if (marketplaceVisible) { + hideMarketplace(); + } else { + showMarketplace(); + } +} + +var toolBar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); + +var browseExamplesButton = toolBar.addButton({ + imageURL: toolIconUrl + "market.svg", + objectName: "marketplace", + buttonState: 1, + defaultState: 1, + hoverState: 3, + alpha: 0.9 +}); + +function updateButtonState(visible) { + browseExamplesButton.writeProperty('buttonState', visible ? 0 : 1); + browseExamplesButton.writeProperty('defaultState', visible ? 0 : 1); + browseExamplesButton.writeProperty('hoverState', visible ? 2 : 3); +} +function onMarketplaceWindowVisibilityChanged() { + updateButtonState(marketplaceWindow.visible); + marketplaceVisible = marketplaceWindow.visible; +} + +function onCanWriteAssetsChanged() { + var message = CAN_WRITE_ASSETS + " " + Entities.canWriteAssets(); + if (marketplaceWindow.visible) { + marketplaceWindow.emitScriptEvent(message); + } + if (marketplaceWebTablet) { + marketplaceWebTablet.getOverlayObject().emitScriptEvent(message); + } +} + +function onClick() { + toggleMarketplace(); +} + +browseExamplesButton.clicked.connect(onClick); +marketplaceWindow.visibleChanged.connect(onMarketplaceWindowVisibilityChanged); +Entities.canWriteAssetsChanged.connect(onCanWriteAssetsChanged); + +clearOldTablet(); // Run once at startup, in case there's anything laying around from a crash. +// We could also optionally do something like Window.domainChanged.connect(function () {Script.setTimeout(clearOldTablet, 2000)}), +// but the HUD version stays around, so lets do the same. + +Script.scriptEnding.connect(function () { + toolBar.removeButton("marketplace"); + browseExamplesButton.clicked.disconnect(onClick); + marketplaceWindow.visibleChanged.disconnect(onMarketplaceWindowVisibilityChanged); + Entities.canWriteAssetsChanged.disconnect(onCanWriteAssetsChanged); +}); + +}()); // END LOCAL_SCOPE diff --git a/scripts/system/muteHUD.js b/scripts/system/muteHUD.js new file mode 100644 index 0000000000..722ed65b3d --- /dev/null +++ b/scripts/system/muteHUD.js @@ -0,0 +1,51 @@ +"use strict"; + +// +// goto.js +// scripts/system/ +// +// Created by Howard Stearns on 2 Jun 2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +(function() { // BEGIN LOCAL_SCOPE + +var toolBar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); + +var button = toolBar.addButton({ + objectName: "mute", + imageURL: Script.resolvePath("assets/images/tools/mic.svg"), + visible: true, + buttonState: 1, + defaultState: 1, + hoverState: 3, + alpha: 0.9 +}); + +function onMuteToggled() { + // We could just toggle state, but we're less likely to get out of wack if we read the AudioDevice. + // muted => button "on" state => 1. go figure. + var state = AudioDevice.getMuted() ? 0 : 1; + var hoverState = AudioDevice.getMuted() ? 2 : 3; + button.writeProperty('buttonState', state); + button.writeProperty('defaultState', state); + button.writeProperty('hoverState', hoverState); +} +onMuteToggled(); +function onClicked(){ + var menuItem = "Mute Microphone"; + Menu.setIsOptionChecked(menuItem, !Menu.isOptionChecked(menuItem)); +} +button.clicked.connect(onClicked); +AudioDevice.muteToggled.connect(onMuteToggled); + +Script.scriptEnding.connect(function () { + toolBar.removeButton("mute"); + button.clicked.disconnect(onClicked); + AudioDevice.muteToggled.disconnect(onMuteToggled); +}); + +}()); // END LOCAL_SCOPE diff --git a/scripts/system/palHUD.js b/scripts/system/palHUD.js new file mode 100644 index 0000000000..f148ad5fdb --- /dev/null +++ b/scripts/system/palHUD.js @@ -0,0 +1,658 @@ +"use strict"; +/*jslint vars: true, plusplus: true, forin: true*/ +/*globals Script, AvatarList, Users, Entities, MyAvatar, Camera, Overlays, OverlayWindow, Toolbars, Vec3, Quat, Controller, print, getControllerWorldLocation */ +// +// pal.js +// +// Created by Howard Stearns on December 9, 2016 +// Copyright 2016 High Fidelity, Inc +// +// Distributed under the Apache License, Version 2.0 +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +// hardcoding these as it appears we cannot traverse the originalTextures in overlays??? Maybe I've missed +// something, will revisit as this is sorta horrible. +const 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") +}; +const 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") +}; +const 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") +}; + +const UNSELECTED_COLOR = { red: 0x1F, green: 0xC6, blue: 0xA6}; +const SELECTED_COLOR = {red: 0xF3, green: 0x91, blue: 0x29}; +const HOVER_COLOR = {red: 0xD0, green: 0xD0, blue: 0xD0}; // almost white for now + +(function() { // BEGIN LOCAL_SCOPE + +Script.include("/~/system/libraries/controllers.js"); + +// +// Overlays. +// +var overlays = {}; // Keeps track of all our extended overlay data objects, keyed by target identifier. + +function ExtendedOverlay(key, type, 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, "model", { + url: Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx"), + textures: textures(selected), + ignoreRayIntersection: true + }, false, false); + } else { + this.model = undefined; + } + this.key = key; + this.selected = selected || false; // not undefined + this.hovering = false; + this.activeOverlay = Overlays.addOverlay(type, properties); // We could use different overlays for (un)selected... +} +// Instance methods: +ExtendedOverlay.prototype.deleteOverlay = function () { // remove display and data of this overlay + Overlays.deleteOverlay(this.activeOverlay); + delete overlays[this.key]; +}; + +ExtendedOverlay.prototype.editOverlay = function (properties) { // change display of this overlay + Overlays.editOverlay(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; + } else { + 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) { + var pickedOverlay = Overlays.findRayIntersection(pickRay); // 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 = Overlays.addOverlay('cube', { + position: entityProperties.position, + rotation: entityProperties.rotation, + dimensions: entityProperties.dimensions, + solid: false, + color: { + red: 0xF3, + green: 0x91, + blue: 0x29 + }, + lineWidth: 1.0, + ignoreRayIntersection: true, + drawInFront: false // Arguable. For now, let's not distract with mysterious wires around the scene. + }); + HighlightedEntity.overlays.push(this); +} +HighlightedEntity.overlays = []; +HighlightedEntity.clearOverlays = function clearHighlightedEntities() { + HighlightedEntity.overlays.forEach(function (highlighted) { + Overlays.deleteOverlay(highlighted.overlay); + }); + HighlightedEntity.overlays = []; +}; +HighlightedEntity.updateOverlays = function updateHighlightedEntities() { + HighlightedEntity.overlays.forEach(function (highlighted) { + var properties = Entities.getEntityProperties(highlighted.id, ['position', 'rotation', 'dimensions']); + Overlays.editOverlay(highlighted.overlay, { + position: properties.position, + rotation: properties.rotation, + dimensions: properties.dimensions + }); + }); +}; + +// +// The qml window and communications. +// +var pal = new OverlayWindow({ + title: 'People Action List', + source: 'hifi/Pal.qml', + width: 580, + height: 640, + visible: false +}); +pal.fromQml.connect(function (message) { // messages are {method, params}, like json-rpc. See also sendToQml. + print('From PAL QML:', JSON.stringify(message)); + 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': + removeOverlays(); + populateUserList(message.params); + UserActivityLogger.palAction("refresh", ""); + break; + case 'updateGain': + data = message.params; + if (data['isReleased']) { + // isReleased=true happens once at the end of a cycle of dragging + // the slider about, but with same gain as last isReleased=false so + // we don't set the gain in that case, and only here do we want to + // send an analytic event. + UserActivityLogger.palAction("avatar_gain_changed", data['sessionId']); + } else { + Users.setAvatarGain(data['sessionId'], data['gain']); + } + break; + case 'displayNameUpdate': + if (MyAvatar.displayName != message.params) { + MyAvatar.displayName = message.params; + UserActivityLogger.palAction("display_name_change", ""); + } + break; + default: + print('Unrecognized message from Pal.qml:', JSON.stringify(message)); + } +}); + +// +// Main operations. +// +function addAvatarNode(id) { + var selected = ExtendedOverlay.isSelected(id); + return new ExtendedOverlay(id, "sphere", { + drawInFront: true, + solid: true, + alpha: 0.8, + color: color(selected, false, 0.0), + ignoreRayIntersection: false}, selected, true); +} +function populateUserList(selectData) { + var data = []; + AvatarList.getAvatarIdentifiers().sort().forEach(function (id) { // sorting the identifiers is just an aid for debugging + var avatar = AvatarList.getAvatar(id); + var avatarPalDatum = { + displayName: avatar.sessionDisplayName, + userName: '', + sessionId: id || '', + audioLevel: 0.0, + admin: false + }; + // Request the username, fingerprint, and admin status from the given UUID + // Username and fingerprint returns default constructor output if the requesting user isn't an admin + Users.requestUsernameFromID(id); + // Request personal mute status and ignore status + // from NodeList (as long as we're not requesting it for our own ID) + if (id) { + avatarPalDatum['personalMute'] = Users.getPersonalMuteStatus(id); + avatarPalDatum['ignore'] = Users.getIgnoreStatus(id); + addAvatarNode(id); // No overlay for ourselves + } + data.push(avatarPalDatum); + print('PAL data:', JSON.stringify(avatarPalDatum)); + }); + pal.sendToQml({ method: 'users', params: data }); + if (selectData) { + selectData[2] = true; + pal.sendToQml({ method: 'select', params: selectData }); + } +} + +// The function that handles the reply from the server +function usernameFromIDReply(id, username, machineFingerprint, isAdmin) { + var data; + // If the ID we've received is our ID... + if (MyAvatar.sessionUUID === id) { + // Set the data to contain specific strings. + data = ['', username, isAdmin]; + } else if (Users.canKick) { + // Set the data to contain the ID and the username (if we have one) + // or fingerprint (if we don't have a username) string. + data = [id, username || machineFingerprint, isAdmin]; + } else { + // Set the data to contain specific strings. + data = [id, '', isAdmin]; + } + print('Username Data:', JSON.stringify(data)); + // Ship the data off to QML + pal.sendToQml({ method: 'updateUsername', params: data }); +} + +var pingPong = true; +function updateOverlays() { + var eye = Camera.position; + AvatarList.getAvatarIdentifiers().forEach(function (id) { + if (!id) { + return; // don't update ourself + } + + var overlay = ExtendedOverlay.get(id); + 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', id); + overlay = addAvatarNode(id); + } + var avatar = AvatarList.getAvatar(id); + var target = avatar.position; + var distance = Vec3.distance(target, eye); + var offset = 0.2; + + // base offset on 1/2 distance from hips to head if we can + var headIndex = avatar.getJointIndex("Head"); + if (headIndex > 0) { + offset = avatar.getAbsoluteJointTranslationInObjectFrame(headIndex).y / 2; + } + + // get diff between target and eye (a vector pointing to the eye from avatar position) + var diff = Vec3.subtract(target, eye); + + // 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(id), 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-populateUserList 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]}; + pal.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; +const TRIGGER_CLICK_THRESHOLD = 0.85; +const TRIGGER_PRESS_THRESHOLD = 0.05; + +function handleMouseMoveEvent(event) { // find out which overlay (if any) is over the mouse position + 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. + 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(Controller.Standard.RTClick).peek().to(makeClickHandler(Controller.Standard.RightHand)); +triggerMapping.from(Controller.Standard.LTClick).peek().to(makeClickHandler(Controller.Standard.LeftHand)); +triggerPressMapping.from(Controller.Standard.RT).peek().to(makePressHandler(Controller.Standard.RightHand)); +triggerPressMapping.from(Controller.Standard.LT).peek().to(makePressHandler(Controller.Standard.LeftHand)); +// +// 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 (!pal.visible) { + onClicked(); + } + pal.sendToQml(message); // Accepts objects, not just strings. + break; + default: + print('Unrecognized PAL message', messageString); + } +} +Messages.subscribe(CHANNEL); +Messages.messageReceived.connect(receiveMessage); + + +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_LEVEL_UPDATE_INTERVAL_MS = 100; // 10hz for now (change this and change the AVERAGING_RATIO too) +var myData = {}; // we're not includied in ExtendedOverlay.get. +var audioInterval; + +function getAudioLevel(id) { + // 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 avatar = AvatarList.getAvatar(id); + var audioLevel = 0.0; + var data = id ? ExtendedOverlay.get(id) : myData; + if (!data) { + return audioLevel; + } + + // we will do exponential moving average by taking some the last loudness and averaging + data.accumulatedLevel = AVERAGING_RATIO * (data.accumulatedLevel || 0) + (1 - AVERAGING_RATIO) * (avatar.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). + var logLevel = Math.log(data.accumulatedLevel + 1) / LOG2; + + if (logLevel <= LOUDNESS_FLOOR) { + audioLevel = logLevel / LOUDNESS_FLOOR * LOUDNESS_SCALE; + } else { + audioLevel = (logLevel - (LOUDNESS_FLOOR - 1.0)) * LOUDNESS_SCALE; + } + if (audioLevel > 1.0) { + audioLevel = 1; + } + data.audioLevel = audioLevel; + return audioLevel; +} + +function createAudioInterval() { + // we will update the audioLevels periodically + // TODO: tune for efficiency - expecially with large numbers of avatars + return Script.setInterval(function () { + if (pal.visible) { + var param = {}; + AvatarList.getAvatarIdentifiers().forEach(function (id) { + var level = getAudioLevel(id); + // qml didn't like an object with null/empty string for a key, so... + var userId = id || 0; + param[userId] = level; + }); + pal.sendToQml({method: 'updateAudioLevel', params: param}); + } + }, AUDIO_LEVEL_UPDATE_INTERVAL_MS); +} + +// +// Manage the connection between the button and the window. +// +var toolBar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); +var buttonName = "pal"; +var button = toolBar.addButton({ + objectName: buttonName, + imageURL: Script.resolvePath("assets/images/tools/people.svg"), + visible: true, + hoverState: 2, + defaultState: 1, + buttonState: 1, + alpha: 0.9 +}); + +var isWired = false; +var palOpenedAt; + +function off() { + if (isWired) { // It is not ok to disconnect these twice, hence guard. + Script.update.disconnect(updateOverlays); + Controller.mousePressEvent.disconnect(handleMouseEvent); + Controller.mouseMoveEvent.disconnect(handleMouseMoveEvent); + isWired = false; + } + triggerMapping.disable(); // It's ok if we disable twice. + triggerPressMapping.disable(); // see above + removeOverlays(); + Users.requestsDomainListData = false; + if (palOpenedAt) { + var duration = new Date().getTime() - palOpenedAt; + UserActivityLogger.palOpened(duration / 1000.0); + palOpenedAt = 0; // just a falsy number is good enough. + } + if (audioInterval) { + Script.clearInterval(audioInterval); + } +} +function onClicked() { + if (!pal.visible) { + Users.requestsDomainListData = true; + populateUserList(); + pal.raise(); + isWired = true; + Script.update.connect(updateOverlays); + Controller.mousePressEvent.connect(handleMouseEvent); + Controller.mouseMoveEvent.connect(handleMouseMoveEvent); + triggerMapping.enable(); + triggerPressMapping.enable(); + createAudioInterval(); + palOpenedAt = new Date().getTime(); + } else { + off(); + } + pal.setVisible(!pal.visible); +} +function avatarDisconnected(nodeID) { + // remove from the pal list + pal.sendToQml({method: 'avatarDisconnected', params: [nodeID]}); +} +// +// Button state. +// +function onVisibleChanged() { + button.writeProperty('buttonState', pal.visible ? 0 : 1); + button.writeProperty('defaultState', pal.visible ? 0 : 1); + button.writeProperty('hoverState', pal.visible ? 2 : 3); +} +button.clicked.connect(onClicked); +pal.visibleChanged.connect(onVisibleChanged); +pal.closed.connect(off); +Users.usernameFromIDReply.connect(usernameFromIDReply); +Users.avatarDisconnected.connect(avatarDisconnected); + +function clearLocalQMLDataAndClosePAL() { + pal.sendToQml({ method: 'clearLocalQMLData' }); + if (pal.visible) { + onClicked(); // Close the PAL + } +} +Window.domainChanged.connect(clearLocalQMLDataAndClosePAL); +Window.domainConnectionRefused.connect(clearLocalQMLDataAndClosePAL); + +// +// Cleanup. +// +Script.scriptEnding.connect(function () { + button.clicked.disconnect(onClicked); + toolBar.removeButton(buttonName); + pal.visibleChanged.disconnect(onVisibleChanged); + pal.closed.disconnect(off); + Users.usernameFromIDReply.disconnect(usernameFromIDReply); + Window.domainChanged.disconnect(clearLocalQMLDataAndClosePAL); + Window.domainConnectionRefused.disconnect(clearLocalQMLDataAndClosePAL); + Messages.unsubscribe(CHANNEL); + Messages.messageReceived.disconnect(receiveMessage); + Users.avatarDisconnected.disconnect(avatarDisconnected); + off(); +}); + + +}()); // END LOCAL_SCOPE diff --git a/scripts/system/snapshotHUD.js b/scripts/system/snapshotHUD.js new file mode 100644 index 0000000000..d79a6e46cb --- /dev/null +++ b/scripts/system/snapshotHUD.js @@ -0,0 +1,226 @@ +// +// snapshot.js +// +// Created by David Kelly on 1 August 2016 +// Copyright 2016 High Fidelity, Inc +// +// Distributed under the Apache License, Version 2.0 +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +(function() { // BEGIN LOCAL_SCOPE + +var SNAPSHOT_DELAY = 500; // 500ms +var toolBar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); +var resetOverlays; +var reticleVisible; +var clearOverlayWhenMoving; +var button = toolBar.addButton({ + objectName: "snapshot", + imageURL: Script.resolvePath("assets/images/tools/snap.svg"), + visible: true, + buttonState: 1, + defaultState: 1, + hoverState: 2, + alpha: 0.9, +}); + +function shouldOpenFeedAfterShare() { + var persisted = Settings.getValue('openFeedAfterShare', true); // might answer true, false, "true", or "false" + return persisted && (persisted !== 'false'); +} +function showFeedWindow() { + DialogsManager.showFeed(); +} + +var SNAPSHOT_REVIEW_URL = Script.resolvePath("html/SnapshotReview.html"); + +var outstanding; +function confirmShare(data) { + var dialog = new OverlayWebWindow('Snapshot Review', SNAPSHOT_REVIEW_URL, 800, 520); + function onMessage(message) { + // Receives message from the html dialog via the qwebchannel EventBridge. This is complicated by the following: + // 1. Although we can send POJOs, we cannot receive a toplevel object. (Arrays of POJOs are fine, though.) + // 2. Although we currently use a single image, we would like to take snapshot, a selfie, a 360 etc. all at the + // same time, show the user all of them, and have the user deselect any that they do not want to share. + // So we'll ultimately be receiving a set of objects, perhaps with different post processing for each. + var isLoggedIn; + var needsLogin = false; + switch (message) { + case 'ready': + dialog.emitScriptEvent(data); // Send it. + outstanding = 0; + break; + case 'openSettings': + Desktop.show("hifi/dialogs/GeneralPreferencesDialog.qml", "GeneralPreferencesDialog"); + break; + case 'setOpenFeedFalse': + Settings.setValue('openFeedAfterShare', false) + break; + case 'setOpenFeedTrue': + Settings.setValue('openFeedAfterShare', true) + break; + default: + dialog.webEventReceived.disconnect(onMessage); + dialog.close(); + isLoggedIn = Account.isLoggedIn(); + message.forEach(function (submessage) { + if (submessage.share && !isLoggedIn) { + needsLogin = true; + submessage.share = false; + } + if (submessage.share) { + print('sharing', submessage.localPath); + outstanding++; + Window.shareSnapshot(submessage.localPath, submessage.href); + } else { + print('not sharing', submessage.localPath); + } + }); + if (!outstanding && shouldOpenFeedAfterShare()) { + showFeedWindow(); + } + if (needsLogin) { // after the possible feed, so that the login is on top + Account.checkAndSignalForAccessToken(); + } + } + } + dialog.webEventReceived.connect(onMessage); + dialog.raise(); +} + +function snapshotShared(errorMessage) { + if (!errorMessage) { + print('snapshot uploaded and shared'); + } else { + print(errorMessage); + } + if ((--outstanding <= 0) && shouldOpenFeedAfterShare()) { + showFeedWindow(); + } +} +var href, domainId; +function onClicked() { + // Raising the desktop for the share dialog at end will interact badly with clearOverlayWhenMoving. + // Turn it off now, before we start futzing with things (and possibly moving). + clearOverlayWhenMoving = MyAvatar.getClearOverlayWhenMoving(); // Do not use Settings. MyAvatar keeps a separate copy. + MyAvatar.setClearOverlayWhenMoving(false); + + // We will record snapshots based on the starting location. That could change, e.g., when recording a .gif. + // Even the domainId could change (e.g., if the user falls into a teleporter while recording). + href = location.href; + domainId = location.domainId; + + // update button states + resetOverlays = Menu.isOptionChecked("Overlays"); // For completness. Certainly true if the button is visible to be clicke. + reticleVisible = Reticle.visible; + Reticle.visible = false; + Window.snapshotTaken.connect(resetButtons); + + button.writeProperty("buttonState", 0); + button.writeProperty("defaultState", 0); + button.writeProperty("hoverState", 2); + + // hide overlays if they are on + if (resetOverlays) { + Menu.setIsOptionChecked("Overlays", false); + } + + // hide hud + toolBar.writeProperty("visible", false); + + // take snapshot (with no notification) + Script.setTimeout(function () { + Window.takeSnapshot(false, true, 1.91); + }, SNAPSHOT_DELAY); +} + +function isDomainOpen(id) { + var request = new XMLHttpRequest(); + var options = [ + 'now=' + new Date().toISOString(), + 'include_actions=concurrency', + 'domain_id=' + id.slice(1, -1), + 'restriction=open,hifi' // If we're sharing, we're logged in + // If we're here, protocol matches, and it is online + ]; + var url = location.metaverseServerUrl + "/api/v1/user_stories?" + options.join('&'); + request.open("GET", url, false); + request.send(); + if (request.status != 200) { + return false; + } + var response = JSON.parse(request.response); // Not parsed for us. + return (response.status === 'success') && + response.total_entries; +} + +function resetButtons(pathStillSnapshot, pathAnimatedSnapshot, notify) { + // If we're not taking an animated snapshot, we have to show the HUD. + // If we ARE taking an animated snapshot, we've already re-enabled the HUD by this point. + if (pathAnimatedSnapshot === "") { + // show hud + toolBar.writeProperty("visible", true); + Reticle.visible = reticleVisible; + // show overlays if they were on + if (resetOverlays) { + Menu.setIsOptionChecked("Overlays", true); + } + } else { + // Allow the user to click the snapshot HUD button again + button.clicked.connect(onClicked); + } + // update button states + button.writeProperty("buttonState", 1); + button.writeProperty("defaultState", 1); + button.writeProperty("hoverState", 3); + Window.snapshotTaken.disconnect(resetButtons); + + // A Snapshot Review dialog might be left open indefinitely after taking the picture, + // during which time the user may have moved. So stash that info in the dialog so that + // it records the correct href. (We can also stash in .jpegs, but not .gifs.) + // last element in data array tells dialog whether we can share or not + var confirmShareContents = [ + { localPath: pathStillSnapshot, href: href }, + { + canShare: !!isDomainOpen(domainId), + openFeedAfterShare: shouldOpenFeedAfterShare() + }]; + if (pathAnimatedSnapshot !== "") { + confirmShareContents.unshift({ localPath: pathAnimatedSnapshot, href: href }); + } + confirmShare(confirmShareContents); + if (clearOverlayWhenMoving) { + MyAvatar.setClearOverlayWhenMoving(true); // not until after the share dialog + } +} + +function processingGif() { + // show hud + toolBar.writeProperty("visible", true); + Reticle.visible = reticleVisible; + + // update button states + button.writeProperty("buttonState", 0); + button.writeProperty("defaultState", 0); + button.writeProperty("hoverState", 2); + // Don't allow the user to click the snapshot button yet + button.clicked.disconnect(onClicked); + // show overlays if they were on + if (resetOverlays) { + Menu.setIsOptionChecked("Overlays", true); + } +} + +button.clicked.connect(onClicked); +Window.snapshotShared.connect(snapshotShared); +Window.processingGif.connect(processingGif); + +Script.scriptEnding.connect(function () { + toolBar.removeButton("snapshot"); + button.clicked.disconnect(onClicked); + Window.snapshotShared.disconnect(snapshotShared); + Window.processingGif.disconnect(processingGif); +}); + +}()); // END LOCAL_SCOPE diff --git a/scripts/system/usersHUD.js b/scripts/system/usersHUD.js new file mode 100644 index 0000000000..8c52240aa9 --- /dev/null +++ b/scripts/system/usersHUD.js @@ -0,0 +1,1237 @@ +"use strict"; + +// +// users.js +// examples +// +// Created by David Rowe on 9 Mar 2015. +// Copyright 2015 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +(function() { // BEGIN LOCAL_SCOPE + +// resolve these paths immediately +var MIN_MAX_BUTTON_SVG = Script.resolvePath("assets/images/tools/min-max-toggle.svg"); +var BASE_URL = Script.resolvePath("assets/images/tools/"); + +var PopUpMenu = function (properties) { + var value = properties.value, + promptOverlay, + valueOverlay, + buttonOverlay, + optionOverlays = [], + isDisplayingOptions = false, + OPTION_MARGIN = 4, + + MIN_MAX_BUTTON_SVG_WIDTH = 17.1, + MIN_MAX_BUTTON_SVG_HEIGHT = 32.5, + MIN_MAX_BUTTON_WIDTH = 14, + MIN_MAX_BUTTON_HEIGHT = MIN_MAX_BUTTON_WIDTH; + + function positionDisplayOptions() { + var y, + i; + + y = properties.y - (properties.values.length - 1) * properties.lineHeight - OPTION_MARGIN; + + for (i = 0; i < properties.values.length; i += 1) { + Overlays.editOverlay(optionOverlays[i], { + y: y + }); + y += properties.lineHeight; + } + } + + function showDisplayOptions() { + var i, + yOffScreen = Controller.getViewportDimensions().y; + + for (i = 0; i < properties.values.length; i += 1) { + optionOverlays[i] = Overlays.addOverlay("text", { + x: properties.x + properties.promptWidth, + y: yOffScreen, + width: properties.width - properties.promptWidth, + height: properties.textHeight + OPTION_MARGIN, // Only need to add margin at top to balance descenders + topMargin: OPTION_MARGIN, + leftMargin: OPTION_MARGIN, + color: properties.optionColor, + alpha: properties.optionAlpha, + backgroundColor: properties.popupBackgroundColor, + backgroundAlpha: properties.popupBackgroundAlpha, + text: properties.displayValues[i], + font: properties.font, + visible: true + }); + } + + positionDisplayOptions(); + + isDisplayingOptions = true; + } + + function deleteDisplayOptions() { + var i; + + for (i = 0; i < optionOverlays.length; i += 1) { + Overlays.deleteOverlay(optionOverlays[i]); + } + + isDisplayingOptions = false; + } + + function handleClick(overlay) { + var clicked = false, + i; + + if (overlay === valueOverlay || overlay === buttonOverlay) { + showDisplayOptions(); + return true; + } + + if (isDisplayingOptions) { + for (i = 0; i < optionOverlays.length; i += 1) { + if (overlay === optionOverlays[i]) { + value = properties.values[i]; + Overlays.editOverlay(valueOverlay, { + text: properties.displayValues[i] + }); + clicked = true; + } + } + + deleteDisplayOptions(); + } + + return clicked; + } + + function updatePosition(x, y) { + properties.x = x; + properties.y = y; + Overlays.editOverlay(promptOverlay, { + x: x, + y: y + }); + Overlays.editOverlay(valueOverlay, { + x: x + properties.promptWidth, + y: y - OPTION_MARGIN + }); + Overlays.editOverlay(buttonOverlay, { + x: x + properties.width - MIN_MAX_BUTTON_WIDTH - 1, + y: y - OPTION_MARGIN + 1 + }); + if (isDisplayingOptions) { + positionDisplayOptions(); + } + } + + function setVisible(visible) { + Overlays.editOverlay(promptOverlay, { + visible: visible + }); + Overlays.editOverlay(valueOverlay, { + visible: visible + }); + Overlays.editOverlay(buttonOverlay, { + visible: visible + }); + } + + function tearDown() { + Overlays.deleteOverlay(promptOverlay); + Overlays.deleteOverlay(valueOverlay); + Overlays.deleteOverlay(buttonOverlay); + if (isDisplayingOptions) { + deleteDisplayOptions(); + } + } + + function getValue() { + return value; + } + + function setValue(newValue) { + var index; + + index = properties.values.indexOf(newValue); + if (index !== -1) { + value = newValue; + Overlays.editOverlay(valueOverlay, { + text: properties.displayValues[index] + }); + } + } + + promptOverlay = Overlays.addOverlay("text", { + x: properties.x, + y: properties.y, + width: properties.promptWidth, + height: properties.textHeight, + topMargin: 0, + leftMargin: 0, + color: properties.promptColor, + alpha: properties.promptAlpha, + backgroundColor: properties.promptBackgroundColor, + backgroundAlpha: properties.promptBackgroundAlpha, + text: properties.prompt, + font: properties.font, + visible: properties.visible + }); + + valueOverlay = Overlays.addOverlay("text", { + x: properties.x + properties.promptWidth, + y: properties.y, + width: properties.width - properties.promptWidth, + height: properties.textHeight + OPTION_MARGIN, // Only need to add margin at top to balance descenders + topMargin: OPTION_MARGIN, + leftMargin: OPTION_MARGIN, + color: properties.optionColor, + alpha: properties.optionAlpha, + backgroundColor: properties.optionBackgroundColor, + backgroundAlpha: properties.optionBackgroundAlpha, + text: properties.displayValues[properties.values.indexOf(value)], + font: properties.font, + visible: properties.visible + }); + + buttonOverlay = Overlays.addOverlay("image", { + x: properties.x + properties.width - MIN_MAX_BUTTON_WIDTH - 1, + y: properties.y, + width: MIN_MAX_BUTTON_WIDTH, + height: MIN_MAX_BUTTON_HEIGHT, + imageURL: MIN_MAX_BUTTON_SVG, + subImage: { + x: 0, + y: 0, + width: MIN_MAX_BUTTON_SVG_WIDTH, + height: MIN_MAX_BUTTON_SVG_HEIGHT / 2 + }, + //color: properties.buttonColor, + alpha: properties.buttonAlpha, + visible: properties.visible + }); + + return { + updatePosition: updatePosition, + setVisible: setVisible, + handleClick: handleClick, + tearDown: tearDown, + getValue: getValue, + setValue: setValue + }; +}; + +var usersWindow = (function () { + + var WINDOW_WIDTH = 260, + WINDOW_MARGIN = 12, + WINDOW_BASE_MARGIN = 24, // A little less is needed in order look correct + WINDOW_FONT = { + size: 12 + }, + WINDOW_FOREGROUND_COLOR = { + red: 240, + green: 240, + blue: 240 + }, + WINDOW_FOREGROUND_ALPHA = 0.95, + WINDOW_HEADING_COLOR = { + red: 180, + green: 180, + blue: 180 + }, + WINDOW_HEADING_ALPHA = 0.95, + WINDOW_BACKGROUND_COLOR = { + red: 80, + green: 80, + blue: 80 + }, + WINDOW_BACKGROUND_ALPHA = 0.8, + windowPane, + windowHeading, + + // Margin on the left and right side of the window to keep + // it from getting too close to the edge of the screen which + // is unclickable. + WINDOW_MARGIN_X = 20, + + // Window border is similar to that of edit.js. + WINDOW_MARGIN_HALF = WINDOW_MARGIN / 2, + WINDOW_BORDER_WIDTH = WINDOW_WIDTH + 2 * WINDOW_MARGIN_HALF, + WINDOW_BORDER_TOP_MARGIN = 2 * WINDOW_MARGIN_HALF, + WINDOW_BORDER_BOTTOM_MARGIN = WINDOW_MARGIN_HALF, + WINDOW_BORDER_LEFT_MARGIN = WINDOW_MARGIN_HALF, + WINDOW_BORDER_RADIUS = 4, + WINDOW_BORDER_COLOR = { red: 255, green: 255, blue: 255 }, + WINDOW_BORDER_ALPHA = 0.5, + windowBorder, + + MIN_MAX_BUTTON_SVG = BASE_URL + "min-max-toggle.svg", + MIN_MAX_BUTTON_SVG_WIDTH = 17.1, + MIN_MAX_BUTTON_SVG_HEIGHT = 32.5, + MIN_MAX_BUTTON_WIDTH = 14, + MIN_MAX_BUTTON_HEIGHT = MIN_MAX_BUTTON_WIDTH, + MIN_MAX_BUTTON_COLOR = { + red: 255, + green: 255, + blue: 255 + }, + MIN_MAX_BUTTON_ALPHA = 0.9, + minimizeButton, + SCROLLBAR_BACKGROUND_WIDTH = 12, + SCROLLBAR_BACKGROUND_COLOR = { + red: 70, + green: 70, + blue: 70 + }, + SCROLLBAR_BACKGROUND_ALPHA = 0.8, + scrollbarBackground, + SCROLLBAR_BAR_MIN_HEIGHT = 5, + SCROLLBAR_BAR_COLOR = { + red: 170, + green: 170, + blue: 170 + }, + SCROLLBAR_BAR_ALPHA = 0.8, + SCROLLBAR_BAR_SELECTED_ALPHA = 0.95, + scrollbarBar, + scrollbarBackgroundHeight, + scrollbarBarHeight, + FRIENDS_BUTTON_SPACER = 6, // Space before add/remove friends button + FRIENDS_BUTTON_SVG = BASE_URL + "add-remove-friends.svg", + FRIENDS_BUTTON_SVG_WIDTH = 107, + FRIENDS_BUTTON_SVG_HEIGHT = 27, + FRIENDS_BUTTON_WIDTH = FRIENDS_BUTTON_SVG_WIDTH, + FRIENDS_BUTTON_HEIGHT = FRIENDS_BUTTON_SVG_HEIGHT, + FRIENDS_BUTTON_COLOR = { + red: 225, + green: 225, + blue: 225 + }, + FRIENDS_BUTTON_ALPHA = 0.95, + FRIENDS_WINDOW_URL = "https://metaverse.highfidelity.com/user/friends", + FRIENDS_WINDOW_WIDTH = 290, + FRIENDS_WINDOW_HEIGHT = 500, + FRIENDS_WINDOW_TITLE = "Add/Remove Friends", + friendsButton, + friendsWindow, + + OPTION_BACKGROUND_COLOR = { + red: 60, + green: 60, + blue: 60 + }, + OPTION_BACKGROUND_ALPHA = 0.1, + + DISPLAY_SPACER = 12, // Space before display control + DISPLAY_PROMPT = "Show me:", + DISPLAY_PROMPT_WIDTH = 60, + DISPLAY_EVERYONE = "everyone", + DISPLAY_FRIENDS = "friends", + DISPLAY_VALUES = [DISPLAY_EVERYONE, DISPLAY_FRIENDS], + DISPLAY_DISPLAY_VALUES = DISPLAY_VALUES, + DISPLAY_OPTIONS_BACKGROUND_COLOR = { + red: 120, + green: 120, + blue: 120 + }, + DISPLAY_OPTIONS_BACKGROUND_ALPHA = 0.9, + displayControl, + + VISIBILITY_SPACER = 6, // Space before visibility control + VISIBILITY_PROMPT = "Visible to:", + VISIBILITY_PROMPT_WIDTH = 60, + VISIBILITY_ALL = "all", + VISIBILITY_FRIENDS = "friends", + VISIBILITY_NONE = "none", + VISIBILITY_VALUES = [VISIBILITY_ALL, VISIBILITY_FRIENDS, VISIBILITY_NONE], + VISIBILITY_DISPLAY_VALUES = ["everyone", "friends", "no one"], + visibilityControl, + + windowHeight, + windowBorderHeight, + windowTextHeight, + windowLineSpacing, + windowLineHeight, // = windowTextHeight + windowLineSpacing + windowMinimumHeight, + + usersOnline, // Raw users data + linesOfUsers = [], // Array of indexes pointing into usersOnline + numUsersToDisplay = 0, + firstUserToDisplay = 0, + + API_URL = "https://metaverse.highfidelity.com/api/v1/users?status=online", + API_FRIENDS_FILTER = "&filter=friends", + HTTP_GET_TIMEOUT = 60000, // ms = 1 minute + usersRequest, + processUsers, + pollUsersTimedOut, + usersTimer = null, + USERS_UPDATE_TIMEOUT = 5000, // ms = 5s + + showMe, + myVisibility, + + MENU_NAME = "View", + MENU_ITEM = "Users Online", + MENU_ITEM_OVERLAYS = "Overlays", + MENU_ITEM_AFTER = MENU_ITEM_OVERLAYS, + + SETTING_USERS_SHOW_ME = "UsersWindow.ShowMe", + SETTING_USERS_VISIBLE_TO = "UsersWindow.VisibleTo", + SETTING_USERS_WINDOW_MINIMIZED = "UsersWindow.Minimized", + SETTING_USERS_WINDOW_OFFSET = "UsersWindow.Offset", + // +ve x, y values are offset from left, top of screen; -ve from right, bottom. + + isLoggedIn = false, + isVisible = true, + isMinimized = false, + isBorderVisible = false, + + viewport, + isMirrorDisplay = false, + isFullscreenMirror = false, + + windowPosition = {}, // Bottom left corner of window pane. + isMovingWindow = false, + movingClickOffset = { x: 0, y: 0 }, + + isUsingScrollbars = false, + isMovingScrollbar = false, + scrollbarBackgroundPosition = {}, + scrollbarBarPosition = {}, + scrollbarBarClickedAt, // 0.0 .. 1.0 + scrollbarValue = 0.0; // 0.0 .. 1.0 + + function isWindowDisabled() { + return !Menu.isOptionChecked(MENU_ITEM) || !Menu.isOptionChecked(MENU_ITEM_OVERLAYS); + } + + function isValueTrue(value) { + // Work around Boolean Settings values being read as string when Interface starts up but as Booleans when re-read after + // Being written if refresh script. + return value === true || value === "true"; + } + + function calculateWindowHeight() { + var AUDIO_METER_HEIGHT = 52, + MIRROR_HEIGHT = 220, + nonUsersHeight, + maxWindowHeight; + + if (isMinimized) { + windowHeight = windowTextHeight + WINDOW_MARGIN + WINDOW_BASE_MARGIN; + windowBorderHeight = windowHeight + WINDOW_BORDER_TOP_MARGIN + WINDOW_BORDER_BOTTOM_MARGIN; + return; + } + + // Reserve space for title, friends button, and option controls + nonUsersHeight = WINDOW_MARGIN + windowLineHeight + + (shouldShowFriendsButton() ? FRIENDS_BUTTON_SPACER + FRIENDS_BUTTON_HEIGHT : 0) + + DISPLAY_SPACER + + windowLineHeight + VISIBILITY_SPACER + + windowLineHeight + WINDOW_BASE_MARGIN; + + // Limit window to height of viewport above window position minus VU meter and mirror if displayed + windowHeight = linesOfUsers.length * windowLineHeight - windowLineSpacing + nonUsersHeight; + maxWindowHeight = windowPosition.y - AUDIO_METER_HEIGHT; + if (isMirrorDisplay && !isFullscreenMirror) { + maxWindowHeight -= MIRROR_HEIGHT; + } + windowHeight = Math.max(Math.min(windowHeight, maxWindowHeight), nonUsersHeight); + windowBorderHeight = windowHeight + WINDOW_BORDER_TOP_MARGIN + WINDOW_BORDER_BOTTOM_MARGIN; + + // Corresponding number of users to actually display + numUsersToDisplay = Math.max(Math.round((windowHeight - nonUsersHeight) / windowLineHeight), 0); + isUsingScrollbars = 0 < numUsersToDisplay && numUsersToDisplay < linesOfUsers.length; + if (isUsingScrollbars) { + firstUserToDisplay = Math.floor(scrollbarValue * (linesOfUsers.length - numUsersToDisplay)); + } else { + firstUserToDisplay = 0; + scrollbarValue = 0.0; + } + } + + function saturateWindowPosition() { + windowPosition.x = Math.max(WINDOW_MARGIN_X, Math.min(viewport.x - WINDOW_WIDTH - WINDOW_MARGIN_X, windowPosition.x)); + windowPosition.y = Math.max(windowMinimumHeight, Math.min(viewport.y, windowPosition.y)); + } + + function updateOverlayPositions() { + // Overlay positions are all relative to windowPosition; windowPosition is the position of the windowPane overlay. + var windowLeft = windowPosition.x, + windowTop = windowPosition.y - windowHeight, + x, + y; + + Overlays.editOverlay(windowBorder, { + x: windowPosition.x - WINDOW_BORDER_LEFT_MARGIN, + y: windowTop - WINDOW_BORDER_TOP_MARGIN + }); + Overlays.editOverlay(windowPane, { + x: windowLeft, + y: windowTop + }); + Overlays.editOverlay(windowHeading, { + x: windowLeft + WINDOW_MARGIN, + y: windowTop + WINDOW_MARGIN + }); + + Overlays.editOverlay(minimizeButton, { + x: windowLeft + WINDOW_WIDTH - WINDOW_MARGIN / 2 - MIN_MAX_BUTTON_WIDTH, + y: windowTop + WINDOW_MARGIN + }); + + scrollbarBackgroundPosition.x = windowLeft + WINDOW_WIDTH - 0.5 * WINDOW_MARGIN - SCROLLBAR_BACKGROUND_WIDTH; + scrollbarBackgroundPosition.y = windowTop + WINDOW_MARGIN + windowTextHeight; + Overlays.editOverlay(scrollbarBackground, { + x: scrollbarBackgroundPosition.x, + y: scrollbarBackgroundPosition.y + }); + scrollbarBarPosition.y = scrollbarBackgroundPosition.y + 1 + + scrollbarValue * (scrollbarBackgroundHeight - scrollbarBarHeight - 2); + Overlays.editOverlay(scrollbarBar, { + x: scrollbarBackgroundPosition.x + 1, + y: scrollbarBarPosition.y + }); + + + x = windowLeft + WINDOW_MARGIN; + y = windowPosition.y + - DISPLAY_SPACER + - windowLineHeight - VISIBILITY_SPACER + - windowLineHeight - WINDOW_BASE_MARGIN; + if (shouldShowFriendsButton()) { + y -= FRIENDS_BUTTON_HEIGHT; + Overlays.editOverlay(friendsButton, { + x: x, + y: y + }); + y += FRIENDS_BUTTON_HEIGHT; + } + + y += DISPLAY_SPACER; + displayControl.updatePosition(x, y); + + y += windowLineHeight + VISIBILITY_SPACER; + visibilityControl.updatePosition(x, y); + } + + function updateUsersDisplay() { + var displayText = "", + user, + userText, + textWidth, + maxTextWidth, + ellipsisWidth, + reducedTextWidth, + i; + + if (!isMinimized) { + maxTextWidth = WINDOW_WIDTH - (isUsingScrollbars ? SCROLLBAR_BACKGROUND_WIDTH : 0) - 2 * WINDOW_MARGIN; + ellipsisWidth = Overlays.textSize(windowPane, "...").width; + reducedTextWidth = maxTextWidth - ellipsisWidth; + + for (i = 0; i < numUsersToDisplay; i += 1) { + user = usersOnline[linesOfUsers[firstUserToDisplay + i]]; + userText = user.text; + textWidth = user.textWidth; + + if (textWidth > maxTextWidth) { + // Trim and append "..." to fit window width + maxTextWidth = maxTextWidth - Overlays.textSize(windowPane, "...").width; + while (textWidth > reducedTextWidth) { + userText = userText.slice(0, -1); + textWidth = Overlays.textSize(windowPane, userText).width; + } + userText += "..."; + } + + displayText += "\n" + userText; + } + + displayText = displayText.slice(1); // Remove leading "\n". + + scrollbarBackgroundHeight = numUsersToDisplay * windowLineHeight - windowLineSpacing / 2; + Overlays.editOverlay(scrollbarBackground, { + height: scrollbarBackgroundHeight, + visible: isLoggedIn && isUsingScrollbars + }); + scrollbarBarHeight = Math.max(numUsersToDisplay / linesOfUsers.length * scrollbarBackgroundHeight, + SCROLLBAR_BAR_MIN_HEIGHT); + Overlays.editOverlay(scrollbarBar, { + height: scrollbarBarHeight, + visible: isLoggedIn && isUsingScrollbars + }); + } + + Overlays.editOverlay(windowBorder, { + height: windowBorderHeight + }); + + Overlays.editOverlay(windowPane, { + height: windowHeight, + text: displayText + }); + + Overlays.editOverlay(windowHeading, { + text: isLoggedIn ? (linesOfUsers.length > 0 ? "Users online" : "No users online") : "Users online - log in to view" + }); + } + + function shouldShowFriendsButton() { + return isVisible && isLoggedIn && !isMinimized; + } + + function updateOverlayVisibility() { + Overlays.editOverlay(windowBorder, { + visible: isVisible && isBorderVisible + }); + Overlays.editOverlay(windowPane, { + visible: isVisible + }); + Overlays.editOverlay(windowHeading, { + visible: isVisible + }); + Overlays.editOverlay(minimizeButton, { + visible: isVisible + }); + Overlays.editOverlay(scrollbarBackground, { + visible: isVisible && isUsingScrollbars && !isMinimized + }); + Overlays.editOverlay(scrollbarBar, { + visible: isVisible && isUsingScrollbars && !isMinimized + }); + Overlays.editOverlay(friendsButton, { + visible: shouldShowFriendsButton() + }); + displayControl.setVisible(isVisible && !isMinimized); + visibilityControl.setVisible(isVisible && !isMinimized); + } + + function checkLoggedIn() { + var wasLoggedIn = isLoggedIn; + + isLoggedIn = Account.isLoggedIn(); + if (isLoggedIn !== wasLoggedIn) { + if (wasLoggedIn) { + setMinimized(true); + calculateWindowHeight(); + updateOverlayPositions(); + updateUsersDisplay(); + } + + updateOverlayVisibility(); + } + } + + function pollUsers() { + var url = API_URL; + + if (showMe === DISPLAY_FRIENDS) { + url += API_FRIENDS_FILTER; + } + + usersRequest = new XMLHttpRequest(); + usersRequest.open("GET", url, true); + usersRequest.timeout = HTTP_GET_TIMEOUT; + usersRequest.ontimeout = pollUsersTimedOut; + usersRequest.onreadystatechange = processUsers; + usersRequest.send(); + } + + processUsers = function () { + var response, + myUsername, + user, + userText, + i; + + if (usersRequest.readyState === usersRequest.DONE) { + if (usersRequest.status === 200) { + response = JSON.parse(usersRequest.responseText); + if (response.status !== "success") { + print("Error: Request for users status returned status = " + response.status); + usersTimer = Script.setTimeout(pollUsers, HTTP_GET_TIMEOUT); // Try again after a longer delay. + return; + } + if (!response.hasOwnProperty("data") || !response.data.hasOwnProperty("users")) { + print("Error: Request for users status returned invalid data"); + usersTimer = Script.setTimeout(pollUsers, HTTP_GET_TIMEOUT); // Try again after a longer delay. + return; + } + + usersOnline = response.data.users; + myUsername = GlobalServices.username; + linesOfUsers = []; + for (i = 0; i < usersOnline.length; i += 1) { + user = usersOnline[i]; + if (user.username !== myUsername && user.online) { + userText = user.username; + if (user.location.root) { + userText += " @ " + user.location.root.name; + } + + usersOnline[i].text = userText; + usersOnline[i].textWidth = Overlays.textSize(windowPane, userText).width; + + linesOfUsers.push(i); + } + } + + checkLoggedIn(); + calculateWindowHeight(); + updateUsersDisplay(); + updateOverlayPositions(); + + } else { + print("Error: Request for users status returned " + usersRequest.status + " " + usersRequest.statusText); + usersTimer = Script.setTimeout(pollUsers, HTTP_GET_TIMEOUT); // Try again after a longer delay. + return; + } + + usersTimer = Script.setTimeout(pollUsers, USERS_UPDATE_TIMEOUT); // Update after finished processing. + } + }; + + pollUsersTimedOut = function () { + print("Error: Request for users status timed out"); + usersTimer = Script.setTimeout(pollUsers, HTTP_GET_TIMEOUT); // Try again after a longer delay. + }; + + function setVisible(visible) { + isVisible = visible; + + if (isVisible) { + if (usersTimer === null) { + pollUsers(); + } + } else { + Script.clearTimeout(usersTimer); + usersTimer = null; + } + + updateOverlayVisibility(); + } + + function setMinimized(minimized) { + isMinimized = minimized; + Overlays.editOverlay(minimizeButton, { + subImage: { + y: isMinimized ? MIN_MAX_BUTTON_SVG_HEIGHT / 2 : 0 + } + }); + updateOverlayVisibility(); + Settings.setValue(SETTING_USERS_WINDOW_MINIMIZED, isMinimized); + } + + function onMenuItemEvent(event) { + if (event === MENU_ITEM) { + setVisible(Menu.isOptionChecked(MENU_ITEM)); + } + } + + function onFindableByChanged(event) { + if (VISIBILITY_VALUES.indexOf(event) !== -1) { + myVisibility = event; + visibilityControl.setValue(event); + Settings.setValue(SETTING_USERS_VISIBLE_TO, myVisibility); + } else { + print("Error: Unrecognized onFindableByChanged value: " + event); + } + } + + function onMousePressEvent(event) { + var clickedOverlay, + numLinesBefore, + overlayX, + overlayY, + minY, + maxY, + lineClicked, + userClicked, + delta; + + if (!isVisible || isWindowDisabled()) { + return; + } + + clickedOverlay = Overlays.getOverlayAtPoint({ + x: event.x, + y: event.y + }); + + if (displayControl.handleClick(clickedOverlay)) { + if (usersTimer !== null) { + Script.clearTimeout(usersTimer); + usersTimer = null; + } + pollUsers(); + showMe = displayControl.getValue(); + Settings.setValue(SETTING_USERS_SHOW_ME, showMe); + return; + } + + if (visibilityControl.handleClick(clickedOverlay)) { + myVisibility = visibilityControl.getValue(); + GlobalServices.findableBy = myVisibility; + Settings.setValue(SETTING_USERS_VISIBLE_TO, myVisibility); + return; + } + + if (clickedOverlay === windowPane) { + + overlayX = event.x - windowPosition.x - WINDOW_MARGIN; + overlayY = event.y - windowPosition.y + windowHeight - WINDOW_MARGIN - windowLineHeight; + + numLinesBefore = Math.round(overlayY / windowLineHeight); + minY = numLinesBefore * windowLineHeight; + maxY = minY + windowTextHeight; + + lineClicked = -1; + if (minY <= overlayY && overlayY <= maxY) { + lineClicked = numLinesBefore; + } + + userClicked = firstUserToDisplay + lineClicked; + + if (0 <= userClicked && userClicked < linesOfUsers.length && 0 <= overlayX + && overlayX <= usersOnline[linesOfUsers[userClicked]].textWidth) { + //print("Go to " + usersOnline[linesOfUsers[userClicked]].username); + location.goToUser(usersOnline[linesOfUsers[userClicked]].username); + } + + return; + } + + if (clickedOverlay === minimizeButton) { + setMinimized(!isMinimized); + calculateWindowHeight(); + updateOverlayPositions(); + updateUsersDisplay(); + return; + } + + if (clickedOverlay === scrollbarBar) { + scrollbarBarClickedAt = (event.y - scrollbarBarPosition.y) / scrollbarBarHeight; + Overlays.editOverlay(scrollbarBar, { + backgroundAlpha: SCROLLBAR_BAR_SELECTED_ALPHA + }); + isMovingScrollbar = true; + return; + } + + if (clickedOverlay === scrollbarBackground) { + delta = scrollbarBarHeight / (scrollbarBackgroundHeight - scrollbarBarHeight); + + if (event.y < scrollbarBarPosition.y) { + scrollbarValue = Math.max(scrollbarValue - delta, 0.0); + } else { + scrollbarValue = Math.min(scrollbarValue + delta, 1.0); + } + + firstUserToDisplay = Math.floor(scrollbarValue * (linesOfUsers.length - numUsersToDisplay)); + updateOverlayPositions(); + updateUsersDisplay(); + return; + } + + if (clickedOverlay === friendsButton) { + if (!friendsWindow) { + friendsWindow = new OverlayWebWindow({ + title: FRIENDS_WINDOW_TITLE, + width: FRIENDS_WINDOW_WIDTH, + height: FRIENDS_WINDOW_HEIGHT, + visible: false + }); + } + friendsWindow.setURL(FRIENDS_WINDOW_URL); + friendsWindow.setVisible(true); + friendsWindow.raise(); + return; + } + + if (clickedOverlay === windowBorder) { + movingClickOffset = { + x: event.x - windowPosition.x, + y: event.y - windowPosition.y + }; + + isMovingWindow = true; + } + } + + function onMouseMoveEvent(event) { + var isVisible; + + if (!isLoggedIn || isWindowDisabled()) { + return; + } + + if (isMovingScrollbar) { + if (scrollbarBackgroundPosition.x - WINDOW_MARGIN <= event.x + && event.x <= scrollbarBackgroundPosition.x + SCROLLBAR_BACKGROUND_WIDTH + WINDOW_MARGIN + && scrollbarBackgroundPosition.y - WINDOW_MARGIN <= event.y + && event.y <= scrollbarBackgroundPosition.y + scrollbarBackgroundHeight + WINDOW_MARGIN) { + scrollbarValue = (event.y - scrollbarBarClickedAt * scrollbarBarHeight - scrollbarBackgroundPosition.y) + / (scrollbarBackgroundHeight - scrollbarBarHeight - 2); + scrollbarValue = Math.min(Math.max(scrollbarValue, 0.0), 1.0); + firstUserToDisplay = Math.floor(scrollbarValue * (linesOfUsers.length - numUsersToDisplay)); + updateOverlayPositions(); + updateUsersDisplay(); + } else { + Overlays.editOverlay(scrollbarBar, { + backgroundAlpha: SCROLLBAR_BAR_ALPHA + }); + isMovingScrollbar = false; + } + } + + if (isMovingWindow) { + windowPosition = { + x: event.x - movingClickOffset.x, + y: event.y - movingClickOffset.y + }; + + saturateWindowPosition(); + calculateWindowHeight(); + updateOverlayPositions(); + updateUsersDisplay(); + + } else { + + isVisible = isBorderVisible; + if (isVisible) { + isVisible = windowPosition.x - WINDOW_BORDER_LEFT_MARGIN <= event.x + && event.x <= windowPosition.x - WINDOW_BORDER_LEFT_MARGIN + WINDOW_BORDER_WIDTH + && windowPosition.y - windowHeight - WINDOW_BORDER_TOP_MARGIN <= event.y + && event.y <= windowPosition.y + WINDOW_BORDER_BOTTOM_MARGIN; + } else { + isVisible = windowPosition.x <= event.x && event.x <= windowPosition.x + WINDOW_WIDTH + && windowPosition.y - windowHeight <= event.y && event.y <= windowPosition.y; + } + if (isVisible !== isBorderVisible) { + isBorderVisible = isVisible; + Overlays.editOverlay(windowBorder, { + visible: isBorderVisible + }); + } + } + } + + function onMouseReleaseEvent() { + var offset = {}; + + if (isWindowDisabled()) { + return; + } + + if (isMovingScrollbar) { + Overlays.editOverlay(scrollbarBar, { + backgroundAlpha: SCROLLBAR_BAR_ALPHA + }); + isMovingScrollbar = false; + } + + if (isMovingWindow) { + // Save offset of bottom of window to nearest edge of the window. + offset.x = (windowPosition.x + WINDOW_WIDTH / 2 < viewport.x / 2) + ? windowPosition.x : windowPosition.x - viewport.x; + offset.y = (windowPosition.y < viewport.y / 2) + ? windowPosition.y : windowPosition.y - viewport.y; + Settings.setValue(SETTING_USERS_WINDOW_OFFSET, JSON.stringify(offset)); + isMovingWindow = false; + } + } + + function onScriptUpdate() { + var oldViewport = viewport, + oldIsMirrorDisplay = isMirrorDisplay, + oldIsFullscreenMirror = isFullscreenMirror, + MIRROR_MENU_ITEM = "Mirror", + FULLSCREEN_MIRROR_MENU_ITEM = "Fullscreen Mirror"; + + if (isWindowDisabled()) { + return; + } + + viewport = Controller.getViewportDimensions(); + isMirrorDisplay = Menu.isOptionChecked(MIRROR_MENU_ITEM); + isFullscreenMirror = Menu.isOptionChecked(FULLSCREEN_MIRROR_MENU_ITEM); + + if (viewport.y !== oldViewport.y || isMirrorDisplay !== oldIsMirrorDisplay + || isFullscreenMirror !== oldIsFullscreenMirror) { + calculateWindowHeight(); + updateUsersDisplay(); + } + + if (viewport.y !== oldViewport.y) { + if (windowPosition.y > oldViewport.y / 2) { + // Maintain position w.r.t. bottom of window. + windowPosition.y = viewport.y - (oldViewport.y - windowPosition.y); + } + } + + if (viewport.x !== oldViewport.x) { + if (windowPosition.x + (WINDOW_WIDTH / 2) > oldViewport.x / 2) { + // Maintain position w.r.t. right of window. + windowPosition.x = viewport.x - (oldViewport.x - windowPosition.x); + } + } + + updateOverlayPositions(); + } + + function setUp() { + var textSizeOverlay, + offsetSetting, + offset = {}, + hmdViewport; + + textSizeOverlay = Overlays.addOverlay("text", { + font: WINDOW_FONT, + visible: false + }); + windowTextHeight = Math.floor(Overlays.textSize(textSizeOverlay, "1").height); + windowLineSpacing = Math.floor(Overlays.textSize(textSizeOverlay, "1\n2").height - 2 * windowTextHeight); + windowLineHeight = windowTextHeight + windowLineSpacing; + windowMinimumHeight = windowTextHeight + WINDOW_MARGIN + WINDOW_BASE_MARGIN; + Overlays.deleteOverlay(textSizeOverlay); + + viewport = Controller.getViewportDimensions(); + + offsetSetting = Settings.getValue(SETTING_USERS_WINDOW_OFFSET); + if (offsetSetting !== "") { + offset = JSON.parse(Settings.getValue(SETTING_USERS_WINDOW_OFFSET)); + } + if (offset.hasOwnProperty("x") && offset.hasOwnProperty("y")) { + windowPosition.x = offset.x < 0 ? viewport.x + offset.x : offset.x; + windowPosition.y = offset.y <= 0 ? viewport.y + offset.y : offset.y; + } else { + hmdViewport = Controller.getRecommendedOverlayRect(); + windowPosition = { + x: (viewport.x - hmdViewport.width) / 2, // HMD viewport is narrower than screen. + y: hmdViewport.height // HMD viewport starts at top of screen but only extends down so far. + }; + } + + saturateWindowPosition(); + calculateWindowHeight(); + + windowBorder = Overlays.addOverlay("rectangle", { + x: 0, + y: viewport.y, // Start up off-screen + width: WINDOW_BORDER_WIDTH, + height: windowBorderHeight, + radius: WINDOW_BORDER_RADIUS, + color: WINDOW_BORDER_COLOR, + alpha: WINDOW_BORDER_ALPHA, + visible: false + }); + + windowPane = Overlays.addOverlay("text", { + x: 0, + y: viewport.y, + width: WINDOW_WIDTH, + height: windowHeight, + topMargin: WINDOW_MARGIN + windowLineHeight, + leftMargin: WINDOW_MARGIN, + color: WINDOW_FOREGROUND_COLOR, + alpha: WINDOW_FOREGROUND_ALPHA, + backgroundColor: WINDOW_BACKGROUND_COLOR, + backgroundAlpha: WINDOW_BACKGROUND_ALPHA, + text: "", + font: WINDOW_FONT, + visible: false + }); + + windowHeading = Overlays.addOverlay("text", { + x: 0, + y: viewport.y, + width: WINDOW_WIDTH - 2 * WINDOW_MARGIN, + height: windowTextHeight, + topMargin: 0, + leftMargin: 0, + color: WINDOW_HEADING_COLOR, + alpha: WINDOW_HEADING_ALPHA, + backgroundAlpha: 0.0, + text: "Users online", + font: WINDOW_FONT, + visible: false + }); + + minimizeButton = Overlays.addOverlay("image", { + x: 0, + y: viewport.y, + width: MIN_MAX_BUTTON_WIDTH, + height: MIN_MAX_BUTTON_HEIGHT, + imageURL: MIN_MAX_BUTTON_SVG, + subImage: { + x: 0, + y: 0, + width: MIN_MAX_BUTTON_SVG_WIDTH, + height: MIN_MAX_BUTTON_SVG_HEIGHT / 2 + }, + color: MIN_MAX_BUTTON_COLOR, + alpha: MIN_MAX_BUTTON_ALPHA, + visible: false + }); + + scrollbarBackgroundPosition = { + x: 0, + y: viewport.y + }; + scrollbarBackground = Overlays.addOverlay("text", { + x: 0, + y: scrollbarBackgroundPosition.y, + width: SCROLLBAR_BACKGROUND_WIDTH, + height: windowTextHeight, + backgroundColor: SCROLLBAR_BACKGROUND_COLOR, + backgroundAlpha: SCROLLBAR_BACKGROUND_ALPHA, + text: "", + visible: false + }); + + scrollbarBarPosition = { + x: 0, + y: viewport.y + }; + scrollbarBar = Overlays.addOverlay("text", { + x: 0, + y: scrollbarBarPosition.y, + width: SCROLLBAR_BACKGROUND_WIDTH - 2, + height: windowTextHeight, + backgroundColor: SCROLLBAR_BAR_COLOR, + backgroundAlpha: SCROLLBAR_BAR_ALPHA, + text: "", + visible: false + }); + + friendsButton = Overlays.addOverlay("image", { + x: 0, + y: viewport.y, + width: FRIENDS_BUTTON_WIDTH, + height: FRIENDS_BUTTON_HEIGHT, + imageURL: FRIENDS_BUTTON_SVG, + subImage: { + x: 0, + y: 0, + width: FRIENDS_BUTTON_SVG_WIDTH, + height: FRIENDS_BUTTON_SVG_HEIGHT + }, + color: FRIENDS_BUTTON_COLOR, + alpha: FRIENDS_BUTTON_ALPHA, + visible: false + }); + + showMe = Settings.getValue(SETTING_USERS_SHOW_ME, ""); + if (DISPLAY_VALUES.indexOf(showMe) === -1) { + showMe = DISPLAY_EVERYONE; + } + + displayControl = new PopUpMenu({ + prompt: DISPLAY_PROMPT, + value: showMe, + values: DISPLAY_VALUES, + displayValues: DISPLAY_DISPLAY_VALUES, + x: 0, + y: viewport.y, + width: WINDOW_WIDTH - 1.5 * WINDOW_MARGIN, + promptWidth: DISPLAY_PROMPT_WIDTH, + lineHeight: windowLineHeight, + textHeight: windowTextHeight, + font: WINDOW_FONT, + promptColor: WINDOW_HEADING_COLOR, + promptAlpha: WINDOW_HEADING_ALPHA, + promptBackgroundColor: WINDOW_BACKGROUND_COLOR, + promptBackgroundAlpha: 0.0, + optionColor: WINDOW_FOREGROUND_COLOR, + optionAlpha: WINDOW_FOREGROUND_ALPHA, + optionBackgroundColor: OPTION_BACKGROUND_COLOR, + optionBackgroundAlpha: OPTION_BACKGROUND_ALPHA, + popupBackgroundColor: DISPLAY_OPTIONS_BACKGROUND_COLOR, + popupBackgroundAlpha: DISPLAY_OPTIONS_BACKGROUND_ALPHA, + buttonColor: MIN_MAX_BUTTON_COLOR, + buttonAlpha: MIN_MAX_BUTTON_ALPHA, + visible: false + }); + + myVisibility = Settings.getValue(SETTING_USERS_VISIBLE_TO, ""); + if (VISIBILITY_VALUES.indexOf(myVisibility) === -1) { + myVisibility = VISIBILITY_FRIENDS; + } + GlobalServices.findableBy = myVisibility; + + visibilityControl = new PopUpMenu({ + prompt: VISIBILITY_PROMPT, + value: myVisibility, + values: VISIBILITY_VALUES, + displayValues: VISIBILITY_DISPLAY_VALUES, + x: 0, + y: viewport.y, + width: WINDOW_WIDTH - 1.5 * WINDOW_MARGIN, + promptWidth: VISIBILITY_PROMPT_WIDTH, + lineHeight: windowLineHeight, + textHeight: windowTextHeight, + font: WINDOW_FONT, + promptColor: WINDOW_HEADING_COLOR, + promptAlpha: WINDOW_HEADING_ALPHA, + promptBackgroundColor: WINDOW_BACKGROUND_COLOR, + promptBackgroundAlpha: 0.0, + optionColor: WINDOW_FOREGROUND_COLOR, + optionAlpha: WINDOW_FOREGROUND_ALPHA, + optionBackgroundColor: OPTION_BACKGROUND_COLOR, + optionBackgroundAlpha: OPTION_BACKGROUND_ALPHA, + popupBackgroundColor: DISPLAY_OPTIONS_BACKGROUND_COLOR, + popupBackgroundAlpha: DISPLAY_OPTIONS_BACKGROUND_ALPHA, + buttonColor: MIN_MAX_BUTTON_COLOR, + buttonAlpha: MIN_MAX_BUTTON_ALPHA, + visible: false + }); + + Controller.mousePressEvent.connect(onMousePressEvent); + Controller.mouseMoveEvent.connect(onMouseMoveEvent); + Controller.mouseReleaseEvent.connect(onMouseReleaseEvent); + + Menu.addMenuItem({ + menuName: MENU_NAME, + menuItemName: MENU_ITEM, + afterItem: MENU_ITEM_AFTER, + isCheckable: true, + isChecked: isVisible + }); + Menu.menuItemEvent.connect(onMenuItemEvent); + + GlobalServices.findableByChanged.connect(onFindableByChanged); + + Script.update.connect(onScriptUpdate); + + pollUsers(); + + // Set minimized at end - setup code does not handle `minimized == false` correctly + setMinimized(isValueTrue(Settings.getValue(SETTING_USERS_WINDOW_MINIMIZED, true))); + } + + function tearDown() { + Menu.removeMenuItem(MENU_NAME, MENU_ITEM); + + Script.clearTimeout(usersTimer); + Overlays.deleteOverlay(windowBorder); + Overlays.deleteOverlay(windowPane); + Overlays.deleteOverlay(windowHeading); + Overlays.deleteOverlay(minimizeButton); + Overlays.deleteOverlay(scrollbarBackground); + Overlays.deleteOverlay(scrollbarBar); + Overlays.deleteOverlay(friendsButton); + displayControl.tearDown(); + visibilityControl.tearDown(); + } + + setUp(); + Script.scriptEnding.connect(tearDown); +}()); + +}()); // END LOCAL_SCOPE