diff --git a/interface/resources/images/FavoriteIconActive.svg b/interface/resources/images/FavoriteIconActive.svg
new file mode 100644
index 0000000000..5f03217d27
--- /dev/null
+++ b/interface/resources/images/FavoriteIconActive.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/interface/resources/images/FavoriteIconInActive.svg b/interface/resources/images/FavoriteIconInActive.svg
new file mode 100644
index 0000000000..7cca31ac66
--- /dev/null
+++ b/interface/resources/images/FavoriteIconInActive.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-1.png b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-1.png
new file mode 100644
index 0000000000..15d177190a
Binary files /dev/null and b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-1.png differ
diff --git a/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-2.png b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-2.png
new file mode 100644
index 0000000000..7c6f96c7aa
Binary files /dev/null and b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-2.png differ
diff --git a/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-3.png b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-3.png
new file mode 100644
index 0000000000..af5a67d260
Binary files /dev/null and b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-3.png differ
diff --git a/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-4.png b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-4.png
new file mode 100644
index 0000000000..2afd2657c4
Binary files /dev/null and b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-4.png differ
diff --git a/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-5.png b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-5.png
new file mode 100644
index 0000000000..52df23ff79
Binary files /dev/null and b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-5.png differ
diff --git a/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d.png b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d.png
new file mode 100644
index 0000000000..a2295a8d88
Binary files /dev/null and b/interface/resources/images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d.png differ
diff --git a/interface/resources/images/samples/hifi-place-77312e4b-6f48-4eb4-87e2-50444d8e56d1.png b/interface/resources/images/samples/hifi-place-77312e4b-6f48-4eb4-87e2-50444d8e56d1.png
new file mode 100644
index 0000000000..d39546a90e
Binary files /dev/null and b/interface/resources/images/samples/hifi-place-77312e4b-6f48-4eb4-87e2-50444d8e56d1.png differ
diff --git a/interface/resources/images/samples/hifi-place-get-avatars.png b/interface/resources/images/samples/hifi-place-get-avatars.png
new file mode 100644
index 0000000000..245a728db7
Binary files /dev/null and b/interface/resources/images/samples/hifi-place-get-avatars.png differ
diff --git a/interface/resources/qml/controls-uit/RadioButton.qml b/interface/resources/qml/controls-uit/RadioButton.qml
index ebfe1ff9a9..9ceba38aa2 100644
--- a/interface/resources/qml/controls-uit/RadioButton.qml
+++ b/interface/resources/qml/controls-uit/RadioButton.qml
@@ -25,10 +25,14 @@ Original.RadioButton {
property int colorScheme: hifi.colorSchemes.light
readonly property bool isLightColorScheme: colorScheme == hifi.colorSchemes.light
- readonly property int boxSize: 14
- readonly property int boxRadius: 3
- readonly property int checkSize: 10
- readonly property int checkRadius: 2
+ property int boxSize: defaultBoxSize
+ property real scaleFactor: boxSize / defaultBoxSize
+
+ readonly property int defaultBoxSize: 14
+ readonly property int boxRadius: 3 * scaleFactor
+ readonly property int checkSize: 10 * scaleFactor
+ readonly property int checkRadius: 2 * scaleFactor
+ readonly property int indicatorRadius: 7 * scaleFactor
onClicked: {
Tablet.playSound(TabletEnums.ButtonClick);
@@ -44,7 +48,7 @@ Original.RadioButton {
id: box
width: boxSize
height: boxSize
- radius: 7
+ radius: indicatorRadius
x: radioButton.leftPadding
y: parent.height / 2 - height / 2
gradient: Gradient {
@@ -66,7 +70,7 @@ Original.RadioButton {
id: check
width: checkSize
height: checkSize
- radius: 7
+ radius: indicatorRadius
anchors.centerIn: parent
color: "#00B4EF"
border.width: 1
diff --git a/interface/resources/qml/controls-uit/SpinBox.qml b/interface/resources/qml/controls-uit/SpinBox.qml
index 52d5a2eb99..43cdf84a55 100644
--- a/interface/resources/qml/controls-uit/SpinBox.qml
+++ b/interface/resources/qml/controls-uit/SpinBox.qml
@@ -27,6 +27,9 @@ SpinBox {
property string suffix: ""
property string labelInside: ""
property color colorLabelInside: hifi.colors.white
+ property color backgroundColor: isLightColorScheme
+ ? (spinBox.activeFocus ? hifi.colors.white : hifi.colors.lightGray)
+ : (spinBox.activeFocus ? hifi.colors.black : hifi.colors.baseGrayShadow)
property real controlHeight: height + (spinBoxLabel.visible ? spinBoxLabel.height + spinBoxLabel.anchors.bottomMargin : 0)
property int decimals: 2;
property real factor: Math.pow(10, decimals)
@@ -69,9 +72,7 @@ SpinBox {
y: spinBoxLabel.visible ? spinBoxLabel.height + spinBoxLabel.anchors.bottomMargin : 0
background: Rectangle {
- color: isLightColorScheme
- ? (spinBox.activeFocus ? hifi.colors.white : hifi.colors.lightGray)
- : (spinBox.activeFocus ? hifi.colors.black : hifi.colors.baseGrayShadow)
+ color: backgroundColor
border.color: spinBoxLabelInside.visible ? spinBoxLabelInside.color : hifi.colors.primaryHighlight
border.width: spinBox.activeFocus ? spinBoxLabelInside.visible ? 2 : 1 : 0
}
diff --git a/interface/resources/qml/hifi/AvatarApp.qml b/interface/resources/qml/hifi/AvatarApp.qml
new file mode 100644
index 0000000000..38a9ea2bb9
--- /dev/null
+++ b/interface/resources/qml/hifi/AvatarApp.qml
@@ -0,0 +1,759 @@
+import QtQuick 2.6
+import QtQuick.Controls 2.2
+import QtQml.Models 2.1
+import QtGraphicalEffects 1.0
+import "../controls-uit" as HifiControls
+import "../styles-uit"
+import "avatarapp"
+
+Rectangle {
+ id: root
+ width: 480
+ height: 706
+ color: style.colors.white
+
+ property string selectedAvatarId: ''
+ onSelectedAvatarIdChanged: {
+ console.debug('selectedAvatarId: ', selectedAvatarId)
+ }
+
+ property var selectedAvatar: selectedAvatarId !== '' ? allAvatars.findAvatar(selectedAvatarId) : undefined
+ onSelectedAvatarChanged: {
+ console.debug('selectedAvatar: ', selectedAvatar ? selectedAvatar.url : selectedAvatar)
+ }
+
+ property string avatarName: selectedAvatar ? selectedAvatar.name : ''
+ property string avatarUrl: selectedAvatar ? selectedAvatar.url : null
+ property int avatarWearablesCount: selectedAvatar && selectedAvatar.wearables !== '' ? selectedAvatar.wearables.split('|').length : 0
+ property bool isAvatarInFavorites: selectedAvatar ? selectedAvatar.favorite : false
+
+ property bool isInManageState: false
+
+ Component.onCompleted: {
+ for(var i = 0; i < allAvatars.count; ++i) {
+ var originalUrl = allAvatars.get(i).url;
+ if(originalUrl !== '') {
+ var resolvedUrl = Qt.resolvedUrl(originalUrl);
+ console.debug('url: ', originalUrl, 'resolved: ', resolvedUrl);
+ allAvatars.setProperty(i, 'url', resolvedUrl);
+ }
+ }
+
+ selectedAvatarId = allAvatars.get(1).url
+ console.debug('wearables: ', selectedAvatar.wearables)
+
+ view.setPage(0)
+ console.debug('view.currentIndex: ', view.currentIndex);
+ }
+
+ AvatarAppStyle {
+ id: style
+ }
+
+ AvatarAppHeader {
+ id: header
+ z: 100
+
+ pageTitle: !settings.visible ? "Avatar" : "Avatar Settings"
+ avatarIconVisible: !settings.visible
+ settingsButtonVisible: !settings.visible
+
+ onSettingsClicked: {
+ settings.open();
+ }
+ }
+
+ Settings {
+ id: settings
+
+ onSaveClicked: function() {
+ close();
+ }
+ onCancelClicked: function() {
+ close();
+ }
+ }
+
+ Rectangle {
+ id: mainBlock
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: header.bottom
+ anchors.bottom: favoritesBlock.top
+
+ TextStyle1 {
+ anchors.left: parent.left
+ anchors.leftMargin: 30
+ anchors.top: parent.top
+ anchors.topMargin: 34
+ }
+
+ TextStyle1 {
+ id: displayNameLabel
+ anchors.left: parent.left
+ anchors.leftMargin: 30
+ anchors.top: parent.top
+ anchors.topMargin: 34
+ text: 'Display Name'
+ }
+
+ InputTextStyle4 {
+ anchors.left: displayNameLabel.right
+ anchors.leftMargin: 30
+ anchors.verticalCenter: displayNameLabel.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: 36
+ width: 232
+ text: 'ThisIsDisplayName'
+
+ HiFiGlyphs {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ size: 36
+ text: "\ue00d"
+ }
+ }
+
+ ShadowImage {
+ id: avatarImage
+ width: 134
+ height: 134
+ anchors.left: displayNameLabel.left
+ anchors.top: displayNameLabel.bottom
+ anchors.topMargin: 31
+ source: avatarUrl
+ }
+
+ AvatarWearablesIndicator {
+ anchors.right: avatarImage.right
+ anchors.bottom: avatarImage.bottom
+ anchors.rightMargin: -radius
+ anchors.bottomMargin: 6.08
+ wearablesCount: avatarWearablesCount
+ visible: avatarWearablesCount !== 0
+ }
+
+ Row {
+ id: star
+
+ anchors.top: parent.top
+ anchors.topMargin: 119
+ anchors.left: avatarImage.right
+ anchors.leftMargin: 30.5
+
+ spacing: 12.3
+
+ Image {
+ width: 21.2
+ height: 19.3
+ source: isAvatarInFavorites ? '../../images/FavoriteIconActive.svg' : '../../images/FavoriteIconInActive.svg'
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ TextStyle5 {
+ text: isAvatarInFavorites ? avatarName : "Add to Favorites"
+ anchors.verticalCenter: parent.verticalCenter
+
+ MouseArea {
+ enabled: !isAvatarInFavorites
+ anchors.fill: parent
+ onClicked: {
+ console.debug('selectedAvatar.url', selectedAvatar.url)
+ createFavorite.onSaveClicked = function() {
+ selectedAvatar.favorite = true;
+ pageOfAvatars.setProperty(view.currentIndex, 'favorite', selectedAvatar.favorite)
+ createFavorite.close();
+ }
+
+ createFavorite.open(selectedAvatar);
+ }
+ }
+ }
+ }
+
+ TextStyle3 {
+ id: avatarNameLabel
+ text: avatarName
+ anchors.left: avatarImage.right
+ anchors.leftMargin: 30
+ anchors.top: parent.top
+ anchors.topMargin: 154
+ }
+
+ TextStyle3 {
+ id: wearablesLabel
+ anchors.left: avatarImage.right
+ anchors.leftMargin: 30
+ anchors.top: avatarNameLabel.bottom
+ anchors.topMargin: 16
+ text: 'Wearables'
+ }
+
+ SquareLabel {
+ anchors.right: parent.right
+ anchors.rightMargin: 30
+ anchors.verticalCenter: avatarNameLabel.verticalCenter
+ glyphText: "."
+ glyphRotation: 45
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ popup.titleText = 'Specify Avatar URL'
+ popup.bodyText = 'If you want to add a custom avatar, you can specify the URL of the avatar file' +
+ '(“.fst” extension) here. Learn to make a custom avatar by opening this link on your desktop.'
+ popup.inputText.visible = true;
+ popup.inputText.placeholderText = 'Enter Avatar Url';
+ popup.button1text = 'CANCEL';
+ popup.button2text = 'CONFIRM';
+ popup.onButton2Clicked = function() {
+ popup.close();
+ }
+
+ popup.open();
+ }
+ }
+ }
+
+ SquareLabel {
+ anchors.right: parent.right
+ anchors.rightMargin: 30
+ anchors.verticalCenter: wearablesLabel.verticalCenter
+ glyphText: "\ue02e"
+
+ visible: avatarWearablesCount !== 0
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ adjustWearables.open();
+ }
+ }
+ }
+
+ TextStyle3 {
+ anchors.right: parent.right
+ anchors.rightMargin: 30
+ anchors.verticalCenter: wearablesLabel.verticalCenter
+ font.underline: true
+ text: "Add"
+ visible: avatarWearablesCount === 0
+
+ MouseArea {
+ anchors.fill: parent
+ property url getWearablesUrl: '../../images/samples/hifi-place-77312e4b-6f48-4eb4-87e2-50444d8e56d1.png'
+
+ // debug only
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ property int debug_newAvatarIndex: 0
+
+ onClicked: {
+ if(mouse.button == Qt.RightButton) {
+
+ for(var i = 0; i < 3; ++i)
+ {
+ console.debug('adding avatar...');
+
+ var avatar = {
+ 'url': '../../images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-2.png',
+ 'name': 'Lexi' + (++debug_newAvatarIndex),
+ 'wearables': '',
+ 'favorite': false
+ };
+
+ allAvatars.append(avatar)
+
+ if(pageOfAvatars.hasGetAvatars())
+ pageOfAvatars.removeGetAvatars();
+
+ if(pageOfAvatars.count !== view.itemsPerPage)
+ pageOfAvatars.append(avatar);
+
+ if(pageOfAvatars.count !== view.itemsPerPage)
+ pageOfAvatars.appendGetAvatars();
+ }
+
+ return;
+ }
+
+ popup.button2text = 'AvatarIsland'
+ popup.button1text = 'CANCEL'
+ popup.titleText = 'Get Wearables'
+ popup.bodyText = 'Buy wearables from Marketplace' + '\n' +
+ 'Wear wearable from My Purchases' + '\n' +
+ 'You can visit the domain “AvatarIsland”' + '\n' +
+ 'to get wearables'
+
+ popup.imageSource = getWearablesUrl;
+ popup.onButton2Clicked = function() {
+ popup.close();
+ gotoAvatarAppPanel.visible = true;
+ }
+ popup.open();
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: favoritesBlock
+ height: 369
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+
+ color: style.colors.lightGrayBackground
+
+ TextStyle1 {
+ id: favoritesLabel
+ anchors.top: parent.top
+ anchors.topMargin: 9
+ anchors.left: parent.left
+ anchors.leftMargin: 30
+ text: "Favorites"
+ }
+
+ TextStyle8 {
+ id: manageLabel
+ anchors.top: parent.top
+ anchors.topMargin: 9
+ anchors.right: parent.right
+ anchors.rightMargin: 30
+ text: isInManageState ? "Back" : "Manage"
+ color: style.colors.blueHighlight
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ isInManageState = isInManageState ? false : true;
+ }
+ }
+ }
+
+ Item {
+ anchors.left: parent.left
+ anchors.leftMargin: 30
+ anchors.right: parent.right
+ anchors.rightMargin: 30
+
+ anchors.top: favoritesLabel.bottom
+ anchors.topMargin: 9
+ anchors.bottom: parent.bottom
+
+ GridView {
+ id: view
+ anchors.fill: parent
+ interactive: false;
+ currentIndex: (selectedAvatarId !== '' && !pageOfAvatars.isUpdating) ? pageOfAvatars.findAvatar(selectedAvatarId) : -1
+
+ AvatarsModel {
+ id: allAvatars
+
+ function findAvatar(avatarId) {
+ console.debug('AvatarsModel: find avatar by', avatarId);
+
+ for(var i = 0; i < count; ++i) {
+ if(get(i).url === avatarId) {
+ console.debug('avatar found by index: ', i)
+ return get(i);
+ }
+ }
+
+ return -1;
+ }
+ }
+
+ property int itemsPerPage: 8
+ property int totalPages: Math.ceil((allAvatars.count + 1) / itemsPerPage)
+ onTotalPagesChanged: {
+ console.debug('total pages: ', totalPages)
+ }
+
+ property int currentPage: 0;
+ onCurrentPageChanged: {
+ console.debug('currentPage: ', currentPage)
+ currentIndex = Qt.binding(function() {
+ return (selectedAvatarId !== '' && !pageOfAvatars.isUpdating) ? pageOfAvatars.findAvatar(selectedAvatarId) : -1
+ })
+ }
+
+ property bool hasNext: currentPage < (totalPages - 1)
+ onHasNextChanged: {
+ console.debug('hasNext: ', hasNext)
+ }
+
+ property bool hasPrev: currentPage > 0
+ onHasPrevChanged: {
+ console.debug('hasPrev: ', hasPrev)
+ }
+
+ function setPage(pageIndex) {
+ pageOfAvatars.isUpdating = true;
+ pageOfAvatars.clear();
+ var start = pageIndex * itemsPerPage;
+ var end = Math.min(start + itemsPerPage, allAvatars.count);
+
+ for(var itemIndex = 0; start < end; ++start, ++itemIndex) {
+ var avatarItem = allAvatars.get(start)
+ console.debug('getting ', start, avatarItem)
+ pageOfAvatars.append(avatarItem);
+ }
+
+ if(pageOfAvatars.count !== itemsPerPage)
+ pageOfAvatars.appendGetAvatars();
+
+ currentPage = pageIndex;
+ console.debug('switched to the page with', pageOfAvatars.count, 'items')
+ pageOfAvatars.isUpdating = false;
+ }
+
+ model: ListModel {
+ id: pageOfAvatars
+
+ property bool isUpdating: false;
+ property var getMoreAvatars: {'url' : '', 'name' : 'Get More Avatars'}
+
+ function findAvatar(avatarId) {
+ console.debug('pageOfAvatars.findAvatar: ', avatarId);
+
+ for(var i = 0; i < count; ++i) {
+ if(get(i).url === avatarId) {
+ console.debug('avatar found by index: ', i)
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ function appendGetAvatars() {
+ append(getMoreAvatars);
+ }
+
+ function hasGetAvatars() {
+ return count != 0 && get(count - 1).url === ''
+ }
+
+ function removeGetAvatars() {
+ if(hasGetAvatars()) {
+ remove(count - 1)
+ console.debug('removed get avatars...');
+ }
+ }
+ }
+
+ flow: GridView.FlowTopToBottom
+
+ cellHeight: 92 + 36
+ cellWidth: 92 + 18
+
+ delegate: Item {
+ id: delegateRoot
+ height: GridView.view.cellHeight
+ width: GridView.view.cellWidth
+
+ Item {
+ id: container
+ width: 92
+ height: 92
+
+ states: [
+ State {
+ name: "hovered"
+ when: favoriteAvatarMouseArea.containsMouse;
+ PropertyChanges { target: container; y: -5 }
+ PropertyChanges { target: favoriteAvatarImage; dropShadowRadius: 10 }
+ PropertyChanges { target: favoriteAvatarImage; dropShadowVerticalOffset: 6 }
+ }
+ ]
+
+ AvatarThumbnail {
+ id: favoriteAvatarImage
+ imageUrl: url
+ wearablesCount: (wearables && wearables !== '') ? wearables.split('|').length : 0
+ onWearablesCountChanged: {
+ console.debug('delegate: AvatarThumbnail.wearablesCount: ', wearablesCount)
+ }
+
+ visible: url !== ''
+
+ MouseArea {
+ id: favoriteAvatarMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ property url getWearablesUrl: '../../images/samples/hifi-place-77312e4b-6f48-4eb4-87e2-50444d8e56d1.png'
+
+ onClicked: {
+ if(isInManageState) {
+ var currentItem = delegateRoot.GridView.view.model.get(index);
+
+ popup.titleText = 'Delete Favorite: {AvatarName}'.replace('{AvatarName}', currentItem.name)
+ popup.bodyText = 'This will delete your favorite. You will retain access to the wearables and avatar that made up the favorite from My Purchases.'
+ popup.imageSource = null;
+ popup.button1text = 'CANCEL'
+ popup.button2text = 'DELETE'
+
+ popup.onButton2Clicked = function() {
+ popup.close();
+
+ pageOfAvatars.isUpdating = true;
+
+ console.debug('removing ', index)
+
+ var absoluteIndex = view.currentPage * view.itemsPerPage + index
+ console.debug('removed ', absoluteIndex, 'view.currentPage', view.currentPage,
+ 'view.itemsPerPage: ', view.itemsPerPage, 'index', index, 'pageOfAvatars', pageOfAvatars, 'pageOfAvatars.count', pageOfAvatars)
+
+ allAvatars.remove(absoluteIndex)
+ pageOfAvatars.remove(index);
+
+ var itemsOnPage = pageOfAvatars.count;
+ var newItemIndex = view.currentPage * view.itemsPerPage + itemsOnPage;
+
+ console.debug('newItemIndex: ', newItemIndex, 'allAvatars.count - 1: ', allAvatars.count - 1, 'pageOfAvatars.count:', pageOfAvatars.count);
+
+ if(newItemIndex <= (allAvatars.count - 1)) {
+ pageOfAvatars.append(allAvatars.get(newItemIndex));
+ } else {
+ if(!pageOfAvatars.hasGetAvatars())
+ pageOfAvatars.appendGetAvatars();
+ }
+
+ console.debug('removed ', absoluteIndex, 'newItemIndex: ', newItemIndex, 'allAvatars.count:', allAvatars.count, 'pageOfAvatars.count:', pageOfAvatars.count)
+ pageOfAvatars.isUpdating = false;
+ };
+
+ popup.open();
+
+ } else {
+ if(delegateRoot.GridView.view.currentIndex !== index) {
+ var currentItem = delegateRoot.GridView.view.model.get(index);
+
+ popup.button2text = 'CONFIRM'
+ popup.button1text = 'CANCEL'
+ popup.titleText = 'Load Favorite: {AvatarName}'.replace('{AvatarName}', currentItem.name)
+ popup.bodyText = 'This will switch your current avatar and ararables that you are wearing with a new avatar and wearables.'
+ popup.imageSource = null;
+ popup.onButton2Clicked = function() {
+ selectedAvatarId = currentItem.url;
+ popup.close();
+ delegateRoot.GridView.view.currentIndex = index;
+ }
+ popup.open();
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: highlight
+ anchors.fill: favoriteAvatarImage
+ visible: delegateRoot.GridView.isCurrentItem
+ color: 'transparent'
+ border.width: 2
+ border.color: style.colors.blueHighlight
+ }
+
+ Colorize {
+ anchors.fill: favoriteAvatarImage
+ source: favoriteAvatarImage
+ saturation: 0.2
+ visible: isInManageState && !highlight.visible
+ }
+
+ HiFiGlyphs {
+ anchors.fill: parent
+ text: "{"
+ visible: isInManageState && !highlight.visible
+ horizontalAlignment: Text.AlignHCenter
+ size: 56
+ }
+
+ ShadowRectangle {
+ width: 92
+ height: 92
+ color: style.colors.blueHighlight
+ visible: url === ''
+
+ HiFiGlyphs {
+ anchors.centerIn: parent
+
+ color: 'white'
+ size: 60
+ text: "K"
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ property url getAvatarsUrl: '../../images/samples/hifi-place-get-avatars.png'
+
+ onClicked: {
+ console.debug('getAvatarsUrl: ', getAvatarsUrl);
+
+ popup.button2text = 'BodyMarkt'
+ popup.button1text = 'CANCEL'
+ popup.titleText = 'Get Avatars'
+
+ popup.bodyText = 'Buy avatars from Marketplace' + '\n' +
+ 'Wear avatars from My Purchases' + '\n' +
+ 'You can visit the domain “BodyMart”' + '\n' +
+ 'to get avatars'
+
+ popup.imageSource = getAvatarsUrl;
+ popup.onButton2Clicked = function() {
+ popup.close();
+ gotoAvatarAppPanel.visible = true;
+ }
+ popup.open();
+ }
+ }
+ }
+ }
+
+ TextStyle7 {
+ id: text
+ width: 92
+ anchors.top: container.bottom
+ anchors.topMargin: 8
+ anchors.horizontalCenter: container.horizontalCenter
+ verticalAlignment: Text.AlignTop
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ text: name
+ }
+ }
+ }
+
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ HiFiGlyphs {
+ rotation: 180
+ text: "\ue01d";
+ size: 50
+ color: view.hasPrev ? 'black' : 'gray'
+ horizontalAlignment: Text.AlignHCenter
+ MouseArea {
+ anchors.fill: parent
+ enabled: view.hasPrev
+ onClicked: {
+ view.setPage(view.currentPage - 1)
+ }
+ }
+ }
+
+ spacing: 0
+
+ HiFiGlyphs {
+ text: "\ue01d";
+ size: 50
+ color: view.hasNext ? 'black' : 'gray'
+ horizontalAlignment: Text.AlignHCenter
+ MouseArea {
+ anchors.fill: parent
+ enabled: view.hasNext
+ onClicked: {
+ view.setPage(view.currentPage + 1)
+ }
+ }
+ }
+
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 19
+ }
+ }
+
+ AdjustWearables {
+ id: adjustWearables
+ z: 2
+ }
+
+ MessageBox {
+ id: popup
+ }
+
+ CreateFavoriteDialog {
+ id: createFavorite
+ }
+
+ Rectangle {
+ id: gotoAvatarAppPanel
+ anchors.fill: parent
+ anchors.leftMargin: 19
+ anchors.rightMargin: 19
+
+ // color: 'green'
+ visible: false
+ onVisibleChanged: {
+ if(visible) {
+ console.debug('selectedAvatar.wearables: ', selectedAvatar.wearables)
+ selectedAvatar.wearables = 'hat|sunglasses|bracelet'
+ pageOfAvatars.setProperty(view.currentIndex, 'wearables', selectedAvatar.wearables)
+ }
+ }
+
+ Rectangle {
+ width: 442
+ height: 447
+ // color: 'yellow'
+
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 259
+
+ TextStyle1 {
+ anchors.fill: parent
+ horizontalAlignment: "AlignHCenter"
+ wrapMode: "WordWrap"
+ text: "You are teleported to “AvatarIsland” VR world and you buy a hat, sunglasses and a bracelet."
+ }
+ }
+
+ Rectangle {
+ width: 442
+ height: 177
+ // color: 'yellow'
+
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 40
+
+ TextStyle1 {
+ anchors.fill: parent
+ horizontalAlignment: "AlignHCenter"
+ wrapMode: "WordWrap"
+ text: 'Click here to open the Avatar app.'
+
+ MouseArea {
+ anchors.fill: parent
+ property int newAvatarIndex: 0
+
+ onClicked: {
+ gotoAvatarAppPanel.visible = false;
+
+ var avatar = {
+ 'url': '../../images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-2.png',
+ 'name': 'Lexi' + (++newAvatarIndex),
+ 'wearables': '',
+ 'favorite': false
+ };
+
+ allAvatars.append(avatar)
+
+ if(pageOfAvatars.hasGetAvatars())
+ pageOfAvatars.removeGetAvatars();
+
+ if(pageOfAvatars.count !== view.itemsPerPage)
+ pageOfAvatars.append(avatar);
+
+ if(pageOfAvatars.count !== view.itemsPerPage)
+ pageOfAvatars.appendGetAvatars();
+
+ console.debug('avatar appended: allAvatars.count: ', allAvatars.count, 'pageOfAvatars.count: ', pageOfAvatars.count);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/AdjustWearables.qml b/interface/resources/qml/hifi/avatarapp/AdjustWearables.qml
new file mode 100644
index 0000000000..c195a4c2b5
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/AdjustWearables.qml
@@ -0,0 +1,183 @@
+import Hifi 1.0 as Hifi
+import QtQuick 2.5
+import "../../styles-uit"
+import "../../controls-uit" as HifiControlsUit
+import "../../controls" as HifiControls
+
+Rectangle {
+ id: root;
+ visible: false;
+ width: 480
+ height: 706
+ color: 'lightgray'
+
+ property bool modified: false;
+ Component.onCompleted: {
+ modified = false;
+ }
+
+ onModifiedChanged: {
+ console.debug('modified: ', modified)
+ }
+
+ property var onButton2Clicked;
+ property var onButton1Clicked;
+
+ function open() {
+ visible = true;
+ }
+
+ function close() {
+ visible = false;
+ }
+
+ HifiConstants { id: hifi }
+
+ // This object is always used in a popup.
+ // This MouseArea is used to prevent a user from being
+ // able to click on a button/mouseArea underneath the popup.
+ MouseArea {
+ anchors.fill: parent;
+ propagateComposedEvents: false;
+ hoverEnabled: true;
+ }
+
+ Column {
+ anchors.top: parent.top
+ anchors.topMargin: 15
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ spacing: 20
+ width: parent.width - 30 * 2
+
+ TextStyle5 {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Adjust Wearables"
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 2
+ color: 'gray'
+ }
+
+ HifiControlsUit.ComboBox {
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ model: [
+ 'Fedora.fbx [HeadTop_End]',
+ 'Fedora1.fbx [HeadTop_End]',
+ 'Fedora2.fbx [HeadTop_End]'
+ ]
+ }
+
+ Column {
+ width: parent.width
+ spacing: 5
+
+ Row {
+ spacing: 20
+
+ TextStyle5 {
+ text: "Position"
+ }
+
+ TextStyle7 {
+ text: "m"
+ }
+ }
+
+ Vector3 {
+ id: position
+ onXvalueChanged: modified = true;
+ onYvalueChanged: modified = true;
+ onZvalueChanged: modified = true;
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: 5
+
+ Row {
+ spacing: 20
+
+ TextStyle5 {
+ text: "Rotation"
+ }
+
+ TextStyle7 {
+ text: "deg"
+ }
+ }
+
+ Vector3 {
+ id: rotation
+ onXvalueChanged: modified = true;
+ onYvalueChanged: modified = true;
+ onZvalueChanged: modified = true;
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: 5
+
+ TextStyle5 {
+ text: "Scale"
+ }
+
+ Item {
+ width: parent.width
+ height: childrenRect.height
+
+ HifiControlsUit.SpinBox {
+ id: scalespinner
+ value: 0
+ backgroundColor: "darkgray"
+ width: position.spinboxWidth
+ colorScheme: hifi.colorSchemes.light
+ onValueChanged: modified = true;
+ }
+
+ HifiControlsUit.Button {
+ anchors.right: parent.right
+ color: hifi.buttons.red;
+ colorScheme: hifi.colorSchemes.dark;
+ text: "TAKE IT OFF"
+ }
+ }
+
+ }
+ }
+
+ DialogButtons {
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 30
+ anchors.left: parent.left
+ anchors.leftMargin: 30
+ anchors.right: parent.right
+ anchors.rightMargin: 30
+
+ yesText: "SAVE"
+ noText: "CANCEL"
+
+ onYesClicked: function() {
+ if(onButton2Clicked) {
+ onButton2Clicked();
+ } else {
+ root.close();
+ }
+ }
+
+ onNoClicked: function() {
+ if(onButton1Clicked) {
+ onButton1Clicked();
+ } else {
+ root.close();
+ }
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/AvatarAppHeader.qml b/interface/resources/qml/hifi/avatarapp/AvatarAppHeader.qml
new file mode 100644
index 0000000000..474ceb5f59
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/AvatarAppHeader.qml
@@ -0,0 +1,57 @@
+import Hifi 1.0 as Hifi
+import QtQuick 2.5
+import "../../styles-uit"
+
+ShadowRectangle {
+ id: header
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 84
+
+ property alias pageTitle: title.text
+ property alias avatarIconVisible: avatarIcon.visible
+ property alias settingsButtonVisible: settingsButton.visible
+
+ signal settingsClicked;
+
+ AvatarAppStyle {
+ id: style
+ }
+
+ color: style.colors.lightGrayBackground
+
+ HiFiGlyphs {
+ id: avatarIcon
+ anchors.left: parent.left
+ anchors.leftMargin: 23
+ anchors.top: parent.top
+ anchors.topMargin: 29
+
+ size: 38
+ text: "<"
+ }
+
+ TextStyle6 {
+ id: title
+ anchors.left: avatarIcon.visible ? avatarIcon.right : avatarIcon.left
+ anchors.leftMargin: 4
+ anchors.verticalCenter: avatarIcon.verticalCenter
+ text: 'Avatar'
+ }
+
+ HiFiGlyphs {
+ id: settingsButton
+ anchors.right: parent.right
+ anchors.rightMargin: 30
+ anchors.verticalCenter: avatarIcon.verticalCenter
+ text: "&"
+
+ MouseArea {
+ id: settingsMouseArea
+ anchors.fill: parent
+ onClicked: {
+ settingsClicked();
+ }
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/AvatarAppStyle.qml b/interface/resources/qml/hifi/avatarapp/AvatarAppStyle.qml
new file mode 100644
index 0000000000..f66c7121cb
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/AvatarAppStyle.qml
@@ -0,0 +1,29 @@
+//
+// HiFiConstants.qml
+//
+// Created by Alexander Ivash on 17 Apr 2018
+// Copyright 2018 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
+//
+
+import QtQuick 2.5
+import QtQuick.Window 2.2
+import "../../styles-uit"
+
+QtObject {
+ readonly property QtObject colors: QtObject {
+ readonly property color lightGrayBackground: "#f2f2f2"
+ readonly property color black: "#000000"
+ readonly property color white: "#ffffff"
+ readonly property color blueHighlight: "#00b4ef"
+ readonly property color inputFieldBackground: "#d4d4d4"
+ readonly property color yellowishOrange: "#ffb017"
+ readonly property color blueAccent: "#0093c5"
+ readonly property color greenHighlight: "#1fc6a6"
+ readonly property color lightGray: "#afafaf"
+ readonly property color redHighlight: "#ea4c5f"
+ readonly property color orangeAccent: "#ff6309"
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/AvatarThumbnail.qml b/interface/resources/qml/hifi/avatarapp/AvatarThumbnail.qml
new file mode 100644
index 0000000000..3fd7bf22b7
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/AvatarThumbnail.qml
@@ -0,0 +1,30 @@
+import QtQuick 2.9
+
+Item {
+ width: 92
+ height: 92
+
+ property int wearablesCount: 0
+ onWearablesCountChanged: {
+ console.debug('AvatarThumbnail: wearablesCount = ', wearablesCount)
+ }
+
+ property alias dropShadowRadius: avatarImage.dropShadowRadius
+ property alias dropShadowHorizontalOffset: avatarImage.dropShadowHorizontalOffset
+ property alias dropShadowVerticalOffset: avatarImage.dropShadowVerticalOffset
+
+ property alias imageUrl: avatarImage.source
+
+ ShadowImage {
+ id: avatarImage
+ anchors.fill: parent
+ }
+
+ AvatarWearablesIndicator {
+ anchors.left: avatarImage.left
+ anchors.bottom: avatarImage.bottom
+ anchors.leftMargin: 57
+ wearablesCount: parent.wearablesCount
+ visible: parent.wearablesCount !== 0
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/AvatarWearablesIndicator.qml b/interface/resources/qml/hifi/avatarapp/AvatarWearablesIndicator.qml
new file mode 100644
index 0000000000..ee720d6a7f
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/AvatarWearablesIndicator.qml
@@ -0,0 +1,40 @@
+import QtQuick 2.9
+import "../../controls-uit"
+import "../../styles-uit"
+
+Rectangle {
+ property int wearablesCount: 0
+
+ width: 46.5
+ height: 46.5
+ radius: width / 2
+
+ AvatarAppStyle {
+ id: style
+ }
+
+ color: style.colors.greenHighlight
+
+ HiFiGlyphs {
+ width: 26.5
+ height: 13.8
+ anchors.top: parent.top
+ anchors.topMargin: 10
+ anchors.horizontalCenter: parent.horizontalCenter
+ horizontalAlignment: Text.AlignHCenter
+ text: "\ue02e"
+ }
+
+ Item {
+ width: 46.57
+ height: 23
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 2.76
+
+ TextStyle2 {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: wearablesCount
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/AvatarsModel.qml b/interface/resources/qml/hifi/avatarapp/AvatarsModel.qml
new file mode 100644
index 0000000000..5672e04731
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/AvatarsModel.qml
@@ -0,0 +1,48 @@
+import QtQuick 2.9
+
+ListModel {
+ id: model
+
+ ListElement {
+ url: '../../images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d.png'
+ name: 'Woody'
+ wearables: ''
+ favorite: false
+ }
+ ListElement {
+ url: '../../images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-1.png'
+ name: 'Damien'
+ wearables: ''
+ favorite: false
+ }
+ ListElement {
+ url: '../../images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-2.png'
+ name: 'Lexi'
+ wearables: ''
+ favorite: false
+ }
+ ListElement {
+ url: '../../images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-3.png'
+ name: 'Judie'
+ wearables: ''
+ favorite: true
+ }
+ ListElement {
+ url: '../../images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-4.png'
+ name: 'Alex'
+ wearables: ''
+ favorite: true
+ }
+ ListElement {
+ url: '../../images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-5.png'
+ name: 'Matthew'
+ wearables: ''
+ favorite: true
+ }
+ ListElement {
+ url: '../../images/samples/hifi-mp-e76946cc-c272-4adf-9bb6-02cde0a4b57d-5.png'
+ name: 'Ogre'
+ wearables: ''
+ favorite: true
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/BlueButton.qml b/interface/resources/qml/hifi/avatarapp/BlueButton.qml
new file mode 100644
index 0000000000..a86f7cdee7
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/BlueButton.qml
@@ -0,0 +1,13 @@
+import QtQuick 2.5
+import "../../styles-uit"
+import "../../controls-uit" as HifiControlsUit
+
+HifiControlsUit.Button {
+ HifiConstants {
+ id: hifi
+ }
+
+ color: hifi.buttons.blue;
+ colorScheme: hifi.colorSchemes.light;
+ height: 40
+}
diff --git a/interface/resources/qml/hifi/avatarapp/CreateFavoriteDialog.qml b/interface/resources/qml/hifi/avatarapp/CreateFavoriteDialog.qml
new file mode 100644
index 0000000000..7056582eac
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/CreateFavoriteDialog.qml
@@ -0,0 +1,136 @@
+import Hifi 1.0 as Hifi
+import QtQuick 2.5
+import "../../styles-uit"
+import "../../controls-uit" as HifiControlsUit
+import "../../controls" as HifiControls
+
+Rectangle {
+ id: root;
+ visible: false;
+ anchors.fill: parent;
+ color: Qt.rgba(0, 0, 0, 0.5);
+ z: 999;
+
+ property string titleText: 'Create Favorite'
+ property string favoriteNameText: favoriteName.text
+ property string avatarImageUrl: null
+ property int wearablesCount: 0
+
+ property string button1color: hifi.buttons.noneBorderlessGray;
+ property string button1text: 'CANCEL'
+ property string button2color: hifi.buttons.blue;
+ property string button2text: 'CONFIRM'
+
+ property var onSaveClicked;
+ property var onCancelClicked;
+
+ function open(avatar) {
+ favoriteName.text = '';
+ avatarImageUrl = avatar.url;
+ wearablesCount = avatar.wearables !== '' ? avatar.wearables.split('|').length : 0;
+
+ visible = true;
+ }
+
+ function close() {
+ console.debug('closing');
+ visible = false;
+ }
+
+ HifiConstants {
+ id: hifi
+ }
+
+ // This object is always used in a popup.
+ // This MouseArea is used to prevent a user from being
+ // able to click on a button/mouseArea underneath the popup.
+ MouseArea {
+ anchors.fill: parent;
+ propagateComposedEvents: false;
+ hoverEnabled: true;
+ }
+
+ Rectangle {
+ id: mainContainer;
+ width: Math.max(parent.width * 0.8, 400)
+ property int margin: 30;
+
+ height: childrenRect.height + margin * 2
+ onHeightChanged: {
+ console.debug('mainContainer: height = ', height)
+ }
+
+ anchors.centerIn: parent
+
+ color: "white"
+
+ TextStyle1 {
+ id: title
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: 30
+ anchors.leftMargin: 30
+ anchors.rightMargin: 30
+
+ text: root.titleText
+ }
+
+ Item {
+ id: contentContainer
+ width: parent.width - 50
+ height: childrenRect.height
+
+ anchors.top: title.bottom
+ anchors.topMargin: 10
+ anchors.left: parent.left;
+ anchors.leftMargin: 30;
+ anchors.right: parent.right;
+ anchors.rightMargin: 30;
+
+ Row {
+ id: bodyRow
+
+ spacing: 44
+
+ AvatarThumbnail {
+ imageUrl: avatarImageUrl
+ wearablesCount: avatarWearablesCount
+ }
+
+ InputTextStyle4 {
+ id: favoriteName
+ anchors.verticalCenter: parent.verticalCenter
+ placeholderText: "Enter Favorite Name"
+ }
+ }
+
+ DialogButtons {
+ anchors.top: bodyRow.bottom
+ anchors.topMargin: 20
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ yesButton.enabled: favoriteNameText !== ''
+ yesText: root.button2text
+ noText: root.button1text
+
+ onYesClicked: function() {
+ if(onSaveClicked) {
+ onSaveClicked();
+ } else {
+ root.close();
+ }
+ }
+
+ onNoClicked: function() {
+ if(onCancelClicked) {
+ onCancelClicked();
+ } else {
+ root.close();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/DialogButtons.qml b/interface/resources/qml/hifi/avatarapp/DialogButtons.qml
new file mode 100644
index 0000000000..46c17bb4dc
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/DialogButtons.qml
@@ -0,0 +1,41 @@
+import QtQuick 2.9
+
+Row {
+ id: root
+ property string yesText;
+ property string noText;
+ property var onYesClicked;
+ property var onNoClicked;
+
+ property alias yesButton: yesButton
+ property alias noButton: noButton
+
+ height: childrenRect.height
+ layoutDirection: Qt.RightToLeft
+
+ spacing: 30
+
+ BlueButton {
+ id: yesButton;
+ text: yesText;
+ onClicked: {
+ console.debug('bluebutton.clicked', onYesClicked);
+
+ if(onYesClicked) {
+ onYesClicked();
+ }
+ }
+ }
+
+ WhiteButton {
+ id: noButton
+ text: noText;
+ onClicked: {
+ console.debug('whitebutton.clicked', onNoClicked);
+
+ if(onNoClicked) {
+ onNoClicked();
+ }
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/InputTextStyle4.qml b/interface/resources/qml/hifi/avatarapp/InputTextStyle4.qml
new file mode 100644
index 0000000000..1e064e7a18
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/InputTextStyle4.qml
@@ -0,0 +1,23 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+import QtQuick 2.0
+import QtQuick.Controls 2.2
+
+TextField {
+ id: control
+ font.family: "Fira Sans"
+ font.pixelSize: 15;
+ color: 'black'
+
+ AvatarAppStyle {
+ id: style
+ }
+
+ background: Rectangle {
+ implicitWidth: 200
+ implicitHeight: 40
+ color: style.colors.inputFieldBackground
+ border.color: style.colors.lightGray
+ }
+}
\ No newline at end of file
diff --git a/interface/resources/qml/hifi/avatarapp/MessageBox.qml b/interface/resources/qml/hifi/avatarapp/MessageBox.qml
new file mode 100644
index 0000000000..eef8ebcb07
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/MessageBox.qml
@@ -0,0 +1,177 @@
+import Hifi 1.0 as Hifi
+import QtQuick 2.5
+import "../../styles-uit"
+import "../../controls-uit" as HifiControlsUit
+import "../../controls" as HifiControls
+
+Rectangle {
+ id: root;
+ visible: false;
+ anchors.fill: parent;
+ color: Qt.rgba(0, 0, 0, 0.5);
+ z: 999;
+
+ property string titleText: ''
+ property string bodyText: ''
+ property alias inputText: input;
+
+ property string imageSource: null
+ onImageSourceChanged: {
+ console.debug('imageSource = ', imageSource)
+ }
+
+ property string button1color: hifi.buttons.noneBorderlessGray;
+ property string button1text: ''
+ property string button2color: hifi.buttons.blue;
+ property string button2text: ''
+
+ property var onButton2Clicked;
+ property var onButton1Clicked;
+
+ function open() {
+ visible = true;
+ }
+
+ function close() {
+ visible = false;
+
+ onButton1Clicked = null;
+ onButton2Clicked = null;
+ button1text = '';
+ button2text = '';
+ imageSource = null;
+ inputText.visible = false;
+ inputText.placeholderText = '';
+ inputText.text = '';
+ }
+
+ HifiConstants {
+ id: hifi
+ }
+
+ // This object is always used in a popup.
+ // This MouseArea is used to prevent a user from being
+ // able to click on a button/mouseArea underneath the popup.
+ MouseArea {
+ anchors.fill: parent;
+ propagateComposedEvents: false;
+ hoverEnabled: true;
+ }
+
+ Rectangle {
+ id: mainContainer;
+ width: Math.max(parent.width * 0.8, 400)
+ property int margin: 30;
+
+ height: childrenRect.height + margin * 2
+ onHeightChanged: {
+ console.debug('mainContainer: height = ', height)
+ }
+
+ anchors.centerIn: parent
+
+ color: "white"
+
+ TextStyle1 {
+ id: title
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: 30
+ anchors.leftMargin: 30
+ anchors.rightMargin: 30
+
+ text: root.titleText
+ }
+
+ Item {
+ id: contentContainer
+ width: parent.width - 60
+ height: childrenRect.height
+ onHeightChanged: {
+ console.debug('contentContainer: height = ', height,
+ 'image.height = ', image.height,
+ 'body.height = ', body.height
+ )
+ }
+
+ anchors.top: title.bottom
+ anchors.topMargin: 10
+ anchors.left: parent.left;
+ anchors.leftMargin: 30;
+ anchors.right: parent.right;
+ anchors.rightMargin: 30;
+
+ TextStyle3 {
+ id: body;
+ text: root.bodyText;
+ anchors.left: parent.left;
+ anchors.right: parent.right;
+ height: paintedHeight;
+ verticalAlignment: Text.AlignTop;
+ wrapMode: Text.WordWrap;
+ }
+
+ Image {
+ id: image
+ Binding on height {
+ when: imageSource === null
+ value: 0
+ }
+
+ anchors.top: body.bottom
+ anchors.topMargin: imageSource === null ? 0 : 30
+ anchors.left: parent.left;
+ anchors.right: parent.right;
+
+ Binding on source {
+ when: imageSource !== null
+ value: imageSource
+ }
+
+ visible: imageSource !== null ? true : false
+ }
+
+ InputTextStyle4 {
+ id: input
+ visible: false
+ height: visible ? implicitHeight : 0
+
+ anchors.top: imageSource !== null ? image.bottom : body.bottom
+ anchors.left: parent.left;
+ anchors.right: parent.right;
+ }
+ }
+
+ DialogButtons {
+ id: buttons
+
+ anchors.top: contentContainer.bottom
+ anchors.topMargin: 30
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.rightMargin: 30
+
+ yesButton.enabled: !input.visible || input.text.length !== 0
+ yesText: root.button2text
+ noText: root.button1text
+
+ onYesClicked: function() {
+ if(onButton2Clicked) {
+ onButton2Clicked();
+ } else {
+ root.close();
+ }
+ }
+
+ onNoClicked: function() {
+ if(onButton1Clicked) {
+ onButton1Clicked();
+ } else {
+ root.close();
+ }
+ }
+ }
+
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/Settings.qml b/interface/resources/qml/hifi/avatarapp/Settings.qml
new file mode 100644
index 0000000000..1ff8cdde28
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/Settings.qml
@@ -0,0 +1,304 @@
+import Hifi 1.0 as Hifi
+import QtQuick 2.5
+import QtQuick.Controls 2.2
+import QtQuick.Layouts 1.3
+import "../../styles-uit"
+import "../../controls-uit" as HifiControlsUit
+import "../../controls" as HifiControls
+
+Rectangle {
+ id: settings
+
+ color: 'white'
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: header.bottom
+ anchors.bottom: parent.bottom
+ visible: false;
+ z: 3
+
+ property alias onSaveClicked: dialogButtons.onYesClicked
+ property alias onCancelClicked: dialogButtons.onNoClicked
+
+ function open() {
+ visible = true;
+ }
+
+ function close() {
+ visible = false
+ }
+
+ Item {
+ anchors.left: parent.left
+ anchors.leftMargin: 27
+ anchors.top: parent.top
+ anchors.topMargin: 25
+ anchors.right: parent.right
+ anchors.rightMargin: 32
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 57
+
+ RowLayout {
+ id: avatarScaleRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ spacing: 17
+
+ RalewaySemiBold {
+ size: 14;
+ text: "Avatar Scale"
+ verticalAlignment: Text.AlignVCenter
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ RowLayout {
+ anchors.verticalCenter: parent.verticalCenter
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ HiFiGlyphs {
+ size: 30
+ text: 'T'
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignHCenter
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Slider {
+ id: slider
+ from: 0
+ to: 100
+ anchors.verticalCenter: parent.verticalCenter
+ Layout.fillWidth: true
+
+ handle: Rectangle {
+ width: 18
+ height: 18
+ color: 'white'
+ radius: 9
+ border.width: 1
+ border.color: 'black'
+ x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width)
+ y: slider.topPadding + slider.availableHeight / 2 - height / 2
+ }
+ background: Rectangle {
+ x: slider.leftPadding
+ y: slider.topPadding + slider.availableHeight / 2 - height / 2
+ implicitWidth: 200
+ implicitHeight: 18
+ width: slider.availableWidth
+ height: implicitHeight
+ radius: 9
+ border.color: 'black'
+ border.width: 1
+ color: '#f2f2f2'
+ }
+ }
+
+ HiFiGlyphs {
+ size: 40
+ text: 'T'
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignHCenter
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ ShadowRectangle {
+ width: 28
+ height: 28
+ color: 'white'
+
+ radius: 3
+ border.color: 'black'
+ border.width: 1.5
+ anchors.verticalCenter: parent.verticalCenter
+
+ RalewaySemiBold {
+ size: 13;
+ text: "1x"
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignHCenter
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+
+ GridLayout {
+ id: handAndCollisions
+ anchors.top: avatarScaleRow.bottom
+ anchors.topMargin: 39
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ rows: 2
+ rowSpacing: 25
+
+ columns: 3
+
+ RalewaySemiBold {
+ Layout.row: 0
+ Layout.column: 0
+
+ size: 14;
+ text: "Dominant Hand"
+ }
+
+ ButtonGroup {
+ id: leftRight
+ }
+
+ HifiControlsUit.RadioButton {
+ id: leftHandRadioButton
+
+ Layout.row: 0
+ Layout.column: 1
+ Layout.leftMargin: -18
+
+ ButtonGroup.group: leftRight
+ checked: true
+
+ text: "Left hand"
+ boxSize: 20
+
+ contentItem: TextStyle9 {
+ text: leftHandRadioButton.text
+ color: 'black'
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ leftPadding: leftHandRadioButton.indicator.width + leftHandRadioButton.spacing
+ }
+ }
+
+ HifiControlsUit.RadioButton {
+ id: rightHandRadioButton
+
+ Layout.row: 0
+ Layout.column: 2
+ ButtonGroup.group: leftRight
+
+ text: "Right hand"
+ boxSize: 20
+
+ contentItem: TextStyle9 {
+ text: rightHandRadioButton.text
+ color: 'black'
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ leftPadding: rightHandRadioButton.indicator.width + rightHandRadioButton.spacing
+ }
+ }
+
+ RalewaySemiBold {
+ Layout.row: 1
+ Layout.column: 0
+
+ size: 14;
+ text: "Avatar Collisions"
+ }
+
+ ButtonGroup {
+ id: onOff
+ }
+
+ HifiControlsUit.RadioButton {
+ id: onRadioButton
+
+ Layout.row: 1
+ Layout.column: 1
+ Layout.leftMargin: -18
+
+ ButtonGroup.group: onOff
+ checked: true
+
+ text: "ON"
+ boxSize: 20
+
+ contentItem: TextStyle9 {
+ text: onRadioButton.text
+ color: 'black'
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ leftPadding: onRadioButton.indicator.width + onRadioButton.spacing
+ }
+ }
+
+ HifiControlsUit.RadioButton {
+ id: offRadioButton
+
+ Layout.row: 1
+ Layout.column: 2
+ ButtonGroup.group: onOff
+
+ text: "OFF"
+ boxSize: 20
+
+ contentItem: TextStyle9 {
+ text: offRadioButton.text
+ color: 'black'
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ leftPadding: offRadioButton.indicator.width + offRadioButton.spacing
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: avatarAnimationLayout
+ anchors.top: handAndCollisions.bottom
+ anchors.topMargin: 25
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ spacing: 4
+
+ RalewaySemiBold {
+ size: 14;
+ text: "Avatar Animation JSON"
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignLeft
+ }
+
+ InputTextStyle4 {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ placeholderText: 'user\\file\\dir'
+ }
+ }
+
+ ColumnLayout {
+ id: avatarCollisionLayout
+ anchors.top: avatarAnimationLayout.bottom
+ anchors.topMargin: 25
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ spacing: 4
+
+ RalewaySemiBold {
+ size: 14;
+ text: "Avatar collision sound URL (optional)"
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignLeft
+ }
+
+ InputTextStyle4 {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ placeholderText: 'https://hifi-public.s3.amazonaws.com/sounds/Collisions-'
+ }
+ }
+
+ DialogButtons {
+ id: dialogButtons
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+
+ yesText: "SAVE"
+ noText: "CANCEL"
+ }
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/ShadowImage.qml b/interface/resources/qml/hifi/avatarapp/ShadowImage.qml
new file mode 100644
index 0000000000..8f8ad587e3
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/ShadowImage.qml
@@ -0,0 +1,26 @@
+import "../../styles-uit"
+import QtQuick 2.9
+import QtGraphicalEffects 1.0
+
+Item {
+ property alias source: image.source
+ property alias dropShadowRadius: shadow.radius
+ property alias dropShadowHorizontalOffset: shadow.horizontalOffset
+ property alias dropShadowVerticalOffset: shadow.verticalOffset
+
+ Image {
+ id: image
+ width: parent.width
+ height: parent.height
+ }
+
+ DropShadow {
+ id: shadow
+ anchors.fill: image
+ radius: 6
+ horizontalOffset: 0
+ verticalOffset: 3
+ color: Qt.rgba(0, 0, 0, 0.25)
+ source: image
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/ShadowRectangle.qml b/interface/resources/qml/hifi/avatarapp/ShadowRectangle.qml
new file mode 100644
index 0000000000..5dc89d5227
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/ShadowRectangle.qml
@@ -0,0 +1,24 @@
+import "../../styles-uit"
+import QtQuick 2.9
+import QtGraphicalEffects 1.0
+
+Item {
+ property alias color: rectangle.color
+ property alias border: rectangle.border
+ property alias radius: rectangle.radius
+
+ Rectangle {
+ id: rectangle
+ width: parent.width
+ height: parent.height
+ }
+
+ DropShadow {
+ anchors.fill: rectangle
+ radius: 6
+ horizontalOffset: 0
+ verticalOffset: 3
+ color: Qt.rgba(0, 0, 0, 0.25)
+ source: rectangle
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/SquareLabel.qml b/interface/resources/qml/hifi/avatarapp/SquareLabel.qml
new file mode 100644
index 0000000000..afe6c751f3
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/SquareLabel.qml
@@ -0,0 +1,21 @@
+import "../../styles-uit"
+import QtQuick 2.9
+import QtGraphicalEffects 1.0
+
+ShadowRectangle {
+ width: 44
+ height: 28
+ color: 'white'
+ property alias glyphText: glyph.text
+ property alias glyphRotation: glyph.rotation
+
+ radius: 3
+ border.color: 'black'
+ border.width: 1.5
+
+ HiFiGlyphs {
+ id: glyph
+ anchors.centerIn: parent
+ size: 30
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle1.qml b/interface/resources/qml/hifi/avatarapp/TextStyle1.qml
new file mode 100644
index 0000000000..58778de440
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle1.qml
@@ -0,0 +1,6 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+RalewaySemiBold {
+ size: 24;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle10.qml b/interface/resources/qml/hifi/avatarapp/TextStyle10.qml
new file mode 100644
index 0000000000..171309a3d0
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle10.qml
@@ -0,0 +1,6 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+RalewayBold {
+ size: 12;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle11.qml b/interface/resources/qml/hifi/avatarapp/TextStyle11.qml
new file mode 100644
index 0000000000..423496c1e6
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle11.qml
@@ -0,0 +1,6 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+RalewayRegular {
+ size: 15;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle2.qml b/interface/resources/qml/hifi/avatarapp/TextStyle2.qml
new file mode 100644
index 0000000000..ae02189ad9
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle2.qml
@@ -0,0 +1,6 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+RalewayBold {
+ size: 15;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle3.qml b/interface/resources/qml/hifi/avatarapp/TextStyle3.qml
new file mode 100644
index 0000000000..a8dea33281
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle3.qml
@@ -0,0 +1,6 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+RalewayRegular {
+ size: 18;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle4.qml b/interface/resources/qml/hifi/avatarapp/TextStyle4.qml
new file mode 100644
index 0000000000..18be5468ef
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle4.qml
@@ -0,0 +1,6 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+FiraSansRegular {
+ size: 15;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle5.qml b/interface/resources/qml/hifi/avatarapp/TextStyle5.qml
new file mode 100644
index 0000000000..1a393b745c
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle5.qml
@@ -0,0 +1,6 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+RalewayBold {
+ size: 18;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle6.qml b/interface/resources/qml/hifi/avatarapp/TextStyle6.qml
new file mode 100644
index 0000000000..bd3c91aa7d
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle6.qml
@@ -0,0 +1,6 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+RalewayLight {
+ size: 18;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle7.qml b/interface/resources/qml/hifi/avatarapp/TextStyle7.qml
new file mode 100644
index 0000000000..37155fa0e2
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle7.qml
@@ -0,0 +1,7 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+FiraSansRegular {
+ size: 18;
+// lineHeight: 16.9;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle8.qml b/interface/resources/qml/hifi/avatarapp/TextStyle8.qml
new file mode 100644
index 0000000000..f557405e5c
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle8.qml
@@ -0,0 +1,6 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+RalewaySemiBold {
+ size: 20;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/TextStyle9.qml b/interface/resources/qml/hifi/avatarapp/TextStyle9.qml
new file mode 100644
index 0000000000..8c8c00df89
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/TextStyle9.qml
@@ -0,0 +1,7 @@
+import "../../controls" as HifiControls
+import "../../styles-uit"
+
+RalewaySemiBold {
+ size: 14;
+ font.letterSpacing: 1.1;
+}
diff --git a/interface/resources/qml/hifi/avatarapp/Vector3.qml b/interface/resources/qml/hifi/avatarapp/Vector3.qml
new file mode 100644
index 0000000000..245f804e82
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/Vector3.qml
@@ -0,0 +1,47 @@
+import Hifi 1.0 as Hifi
+import QtQuick 2.5
+import "../../styles-uit"
+import "../../controls-uit" as HifiControlsUit
+import "../../controls" as HifiControls
+
+Row {
+ width: parent.width
+ height: xspinner.controlHeight
+
+ property int spinboxSpace: 10
+ property int spinboxWidth: (parent.width - 2 * spinboxSpace) / 3
+ property color backgroundColor: "darkgray"
+
+ spacing: spinboxSpace
+
+ property real xvalue: xspinner.value
+ property real yvalue: yspinner.value
+ property real zvalue: zspinner.value
+
+ HifiControlsUit.SpinBox {
+ id: xspinner
+ width: parent.spinboxWidth
+ labelInside: "X:"
+ backgroundColor: parent.backgroundColor
+ colorLabelInside: hifi.colors.redHighlight
+ colorScheme: hifi.colorSchemes.light
+ }
+
+ HifiControlsUit.SpinBox {
+ id: yspinner
+ width: parent.spinboxWidth
+ labelInside: "Y:"
+ backgroundColor: parent.backgroundColor
+ colorLabelInside: hifi.colors.greenHighlight
+ colorScheme: hifi.colorSchemes.light
+ }
+
+ HifiControlsUit.SpinBox {
+ id: zspinner
+ width: parent.spinboxWidth
+ labelInside: "Z:"
+ backgroundColor: parent.backgroundColor
+ colorLabelInside: hifi.colors.primaryHighlight
+ colorScheme: hifi.colorSchemes.light
+ }
+}
diff --git a/interface/resources/qml/hifi/avatarapp/WhiteButton.qml b/interface/resources/qml/hifi/avatarapp/WhiteButton.qml
new file mode 100644
index 0000000000..838af9354c
--- /dev/null
+++ b/interface/resources/qml/hifi/avatarapp/WhiteButton.qml
@@ -0,0 +1,13 @@
+import QtQuick 2.5
+import "../../styles-uit"
+import "../../controls-uit" as HifiControlsUit
+
+HifiControlsUit.Button {
+ HifiConstants {
+ id: hifi
+ }
+
+ color: hifi.buttons.noneBorderlessGray;
+ colorScheme: hifi.colorSchemes.light;
+ height: 40
+}
diff --git a/scripts/defaultScripts.js b/scripts/defaultScripts.js
index ddbeaaeea9..b275660c0f 100644
--- a/scripts/defaultScripts.js
+++ b/scripts/defaultScripts.js
@@ -21,6 +21,7 @@ var DEFAULT_SCRIPTS_COMBINED = [
"system/bubble.js",
"system/snapshot.js",
"system/pal.js", // "system/mod.js", // older UX, if you prefer
+ "system/avatarapp.js",
"system/makeUserConnection.js",
"system/tablet-goto.js",
"system/marketplaces/marketplaces.js",
diff --git a/scripts/system/avatarapp.js b/scripts/system/avatarapp.js
new file mode 100644
index 0000000000..3bad252db1
--- /dev/null
+++ b/scripts/system/avatarapp.js
@@ -0,0 +1,910 @@
+"use strict";
+/*jslint vars:true, plusplus:true, forin:true*/
+/*global Tablet, Settings, Script, AvatarList, Users, Entities, MyAvatar, Camera, Overlays, Vec3, Quat, HMD, Controller, Account, UserActivityLogger, Messages, Window, XMLHttpRequest, print, location, getControllerWorldLocation*/
+/* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */
+//
+// 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
+//
+
+(function() { // BEGIN LOCAL_SCOPE
+
+ var request = Script.require('request').request;
+
+var populateNearbyUserList, color, textures, removeOverlays,
+ controllerComputePickRay, onTabletButtonClicked, onTabletScreenChanged,
+ receiveMessage, avatarDisconnected, clearLocalQMLDataAndClosePAL,
+ createAudioInterval, tablet, CHANNEL, getConnectionData, findableByChanged,
+ avatarAdded, avatarRemoved, avatarSessionChanged; // forward references;
+
+// hardcoding these as it appears we cannot traverse the originalTextures in overlays??? Maybe I've missed
+// something, will revisit as this is sorta horrible.
+var UNSELECTED_TEXTURES = {
+ "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png"),
+ "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png")
+};
+var SELECTED_TEXTURES = {
+ "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png"),
+ "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png")
+};
+var HOVER_TEXTURES = {
+ "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png"),
+ "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png")
+};
+
+var UNSELECTED_COLOR = { red: 0x1F, green: 0xC6, blue: 0xA6};
+var SELECTED_COLOR = {red: 0xF3, green: 0x91, blue: 0x29};
+var HOVER_COLOR = {red: 0xD0, green: 0xD0, blue: 0xD0}; // almost white for now
+var PAL_QML_SOURCE = "hifi/AvatarApp.qml";
+var conserveResources = true;
+
+Script.include("/~/system/libraries/controllers.js");
+
+function projectVectorOntoPlane(normalizedVector, planeNormal) {
+ return Vec3.cross(planeNormal, Vec3.cross(normalizedVector, planeNormal));
+}
+function angleBetweenVectorsInPlane(from, to, normal) {
+ var projectedFrom = projectVectorOntoPlane(from, normal);
+ var projectedTo = projectVectorOntoPlane(to, normal);
+ return Vec3.orientedAngle(projectedFrom, projectedTo, normal);
+}
+
+//
+// Overlays.
+//
+var overlays = {}; // Keeps track of all our extended overlay data objects, keyed by target identifier.
+
+function ExtendedOverlay(key, 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;
+ }
+ 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
+ },
+ 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
+ });
+ });
+};
+
+/* this contains current gain for a given node (by session id). More efficient than
+ * querying it, plus there isn't a getGain function so why write one */
+var sessionGains = {};
+function convertDbToLinear(decibels) {
+ // +20db = 10x, 0dB = 1x, -10dB = 0.1x, etc...
+ // but, your perception is that something 2x as loud is +10db
+ // so we go from -60 to +20 or 1/64x to 4x. For now, we can
+ // maybe scale the signal this way??
+ return Math.pow(2, decibels / 10.0);
+}
+function fromQml(message) { // messages are {method, params}, like json-rpc. See also sendToQml.
+ var data;
+ 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 'refreshNearby':
+ data = {};
+ ExtendedOverlay.some(function (overlay) { // capture the audio data
+ data[overlay.key] = overlay;
+ });
+ removeOverlays();
+ // If filter is specified from .qml instead of through settings, update the settings.
+ if (message.params.filter !== undefined) {
+ Settings.setValue('pal/filtered', !!message.params.filter);
+ }
+ populateNearbyUserList(message.params.selected, data);
+ UserActivityLogger.palAction("refresh_nearby", "");
+ break;
+ case 'refreshConnections':
+ print('Refreshing Connections...');
+ getConnectionData(false);
+ UserActivityLogger.palAction("refresh_connections", "");
+ break;
+ case 'removeConnection':
+ connectionUserName = message.params;
+ request({
+ uri: METAVERSE_BASE + '/api/v1/user/connections/' + connectionUserName,
+ method: 'DELETE'
+ }, function (error, response) {
+ if (error || (response.status !== 'success')) {
+ print("Error: unable to remove connection", connectionUserName, error || response.status);
+ return;
+ }
+ getConnectionData(false);
+ });
+ break
+
+ case 'removeFriend':
+ friendUserName = message.params;
+ print("Removing " + friendUserName + " from friends.");
+ request({
+ uri: METAVERSE_BASE + '/api/v1/user/friends/' + friendUserName,
+ method: 'DELETE'
+ }, function (error, response) {
+ if (error || (response.status !== 'success')) {
+ print("Error: unable to unfriend " + friendUserName, error || response.status);
+ return;
+ }
+ getConnectionData(friendUserName);
+ });
+ break
+ case 'addFriend':
+ friendUserName = message.params;
+ print("Adding " + friendUserName + " to friends.");
+ request({
+ uri: METAVERSE_BASE + '/api/v1/user/friends',
+ method: 'POST',
+ json: true,
+ body: {
+ username: friendUserName,
+ }
+ }, function (error, response) {
+ if (error || (response.status !== 'success')) {
+ print("Error: unable to friend " + friendUserName, error || response.status);
+ return;
+ }
+ getConnectionData(friendUserName);
+ }
+ );
+ break;
+ default:
+ print('Unrecognized message from Pal.qml:', JSON.stringify(message));
+ }
+}
+
+function sendToQml(message) {
+ tablet.sendToQml(message);
+}
+function updateUser(data) {
+ print('PAL update:', JSON.stringify(data));
+ sendToQml({ method: 'updateUsername', params: data });
+}
+//
+// User management services
+//
+// These are prototype versions that will be changed when the back end changes.
+var METAVERSE_BASE = Account.metaverseServerURL;
+
+function requestJSON(url, callback) { // callback(data) if successfull. Logs otherwise.
+ request({
+ uri: url
+ }, function (error, response) {
+ if (error || (response.status !== 'success')) {
+ print("Error: unable to get", url, error || response.status);
+ return;
+ }
+ callback(response.data);
+ });
+}
+function getProfilePicture(username, callback) { // callback(url) if successfull. (Logs otherwise)
+ // FIXME Prototype scrapes profile picture. We should include in user status, and also make available somewhere for myself
+ request({
+ uri: METAVERSE_BASE + '/users/' + username
+ }, function (error, html) {
+ var matched = !error && html.match(/img class="users-img" src="([^"]*)"/);
+ if (!matched) {
+ print('Error: Unable to get profile picture for', username, error);
+ callback('');
+ return;
+ }
+ callback(matched[1]);
+ });
+}
+function getAvailableConnections(domain, callback) { // callback([{usename, location}...]) if successfull. (Logs otherwise)
+ url = METAVERSE_BASE + '/api/v1/users?'
+ if (domain) {
+ url += 'status=' + domain.slice(1, -1); // without curly braces
+ } else {
+ url += 'filter=connections'; // regardless of whether online
+ }
+ requestJSON(url, function (connectionsData) {
+ callback(connectionsData.users);
+ });
+}
+function getInfoAboutUser(specificUsername, callback) {
+ url = METAVERSE_BASE + '/api/v1/users?filter=connections'
+ requestJSON(url, function (connectionsData) {
+ for (user in connectionsData.users) {
+ if (connectionsData.users[user].username === specificUsername) {
+ callback(connectionsData.users[user]);
+ return;
+ }
+ }
+ callback(false);
+ });
+}
+function getConnectionData(specificUsername, domain) { // Update all the usernames that I am entitled to see, using my login but not dependent on canKick.
+ function frob(user) { // get into the right format
+ var formattedSessionId = user.location.node_id || '';
+ if (formattedSessionId !== '' && formattedSessionId.indexOf("{") != 0) {
+ formattedSessionId = "{" + formattedSessionId + "}";
+ }
+ return {
+ sessionId: formattedSessionId,
+ userName: user.username,
+ connection: user.connection,
+ profileUrl: user.images.thumbnail,
+ placeName: (user.location.root || user.location.domain || {}).name || ''
+ };
+ }
+ if (specificUsername) {
+ getInfoAboutUser(specificUsername, function (user) {
+ if (user) {
+ updateUser(frob(user));
+ } else {
+ print('Error: Unable to find information about ' + specificUsername + ' in connectionsData!');
+ }
+ });
+ } else {
+ getAvailableConnections(domain, function (users) {
+ if (domain) {
+ users.forEach(function (user) {
+ updateUser(frob(user));
+ });
+ } else {
+ sendToQml({ method: 'connections', params: users.map(frob) });
+ }
+ });
+ }
+}
+
+//
+// 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, !conserveResources);
+}
+// Each open/refresh will capture a stable set of avatarsOfInterest, within the specified filter.
+var avatarsOfInterest = {};
+function populateNearbyUserList(selectData, oldAudioData) {
+ var filter = Settings.getValue('pal/filtered') && {distance: Settings.getValue('pal/nearDistance')},
+ data = [],
+ avatars = AvatarList.getAvatarIdentifiers(),
+ myPosition = filter && Camera.position,
+ frustum = filter && Camera.frustum,
+ verticalHalfAngle = filter && (frustum.fieldOfView / 2),
+ horizontalHalfAngle = filter && (verticalHalfAngle * frustum.aspectRatio),
+ orientation = filter && Camera.orientation,
+ forward = filter && Quat.getForward(orientation),
+ verticalAngleNormal = filter && Quat.getRight(orientation),
+ horizontalAngleNormal = filter && Quat.getUp(orientation);
+ avatarsOfInterest = {};
+ avatars.forEach(function (id) {
+ var avatar = AvatarList.getAvatar(id);
+ var name = avatar.sessionDisplayName;
+ if (!name) {
+ // Either we got a data packet but no identity yet, or something is really messed up. In any case,
+ // we won't be able to do anything with this user, so don't include them.
+ // In normal circumstances, a refresh will bring in the new user, but if we're very heavily loaded,
+ // we could be losing and gaining people randomly.
+ print('No avatar identity data for', id);
+ return;
+ }
+ if (id && myPosition && (Vec3.distance(avatar.position, myPosition) > filter.distance)) {
+ return;
+ }
+ var normal = id && filter && Vec3.normalize(Vec3.subtract(avatar.position, myPosition));
+ var horizontal = normal && angleBetweenVectorsInPlane(normal, forward, horizontalAngleNormal);
+ var vertical = normal && angleBetweenVectorsInPlane(normal, forward, verticalAngleNormal);
+ if (id && filter && ((Math.abs(horizontal) > horizontalHalfAngle) || (Math.abs(vertical) > verticalHalfAngle))) {
+ return;
+ }
+ var oldAudio = oldAudioData && oldAudioData[id];
+ var avatarPalDatum = {
+ profileUrl: '',
+ displayName: name,
+ userName: '',
+ connection: '',
+ sessionId: id || '',
+ audioLevel: (oldAudio && oldAudio.audioLevel) || 0.0,
+ avgAudioLevel: (oldAudio && oldAudio.avgAudioLevel) || 0.0,
+ admin: false,
+ personalMute: !!id && Users.getPersonalMuteStatus(id), // expects proper boolean, not null
+ ignore: !!id && Users.getIgnoreStatus(id), // ditto
+ isPresent: true,
+ isReplicated: avatar.isReplicated
+ };
+ // Everyone needs to see admin status. Username and fingerprint returns default constructor output if the requesting user isn't an admin.
+ Users.requestUsernameFromID(id);
+ if (id) {
+ addAvatarNode(id); // No overlay for ourselves
+ avatarsOfInterest[id] = true;
+ } else {
+ // Return our username from the Account API
+ avatarPalDatum.userName = Account.username;
+ }
+ data.push(avatarPalDatum);
+ print('PAL data:', JSON.stringify(avatarPalDatum));
+ });
+ getConnectionData(false, location.domainId); // Even admins don't get relationship data in requestUsernameFromID (which is still needed for admin status, which comes from domain).
+ conserveResources = Object.keys(avatarsOfInterest).length > 20;
+ sendToQml({ method: 'nearbyUsers', params: data });
+ if (selectData) {
+ selectData[2] = true;
+ sendToQml({ method: 'select', params: selectData });
+ }
+}
+
+// The function that handles the reply from the server
+function usernameFromIDReply(id, username, machineFingerprint, isAdmin) {
+ var data = {
+ sessionId: (MyAvatar.sessionUUID === id) ? '' : id, // Pal.qml recognizes empty id specially.
+ // If we get username (e.g., if in future we receive it when we're friends), use it.
+ // Otherwise, use valid machineFingerprint (which is not valid when not an admin).
+ userName: username || (Users.canKick && machineFingerprint) || '',
+ admin: isAdmin
+ };
+ // Ship the data off to QML
+ updateUser(data);
+}
+
+var pingPong = true;
+function updateOverlays() {
+ var eye = Camera.position;
+ AvatarList.getAvatarIdentifiers().forEach(function (id) {
+ if (!id || !avatarsOfInterest[id]) {
+ return; // don't update ourself, or avatars we're not interested in
+ }
+ var avatar = AvatarList.getAvatar(id);
+ if (!avatar) {
+ return; // will be deleted below if there had been an overlay.
+ }
+ 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 target = avatar.position;
+ var distance = Vec3.distance(target, eye);
+ var offset = 0.2;
+ var diff = Vec3.subtract(target, eye); // get diff between target and eye (a vector pointing to the eye from avatar position)
+ var headIndex = avatar.getJointIndex("Head"); // base offset on 1/2 distance from hips to head if we can
+ if (headIndex > 0) {
+ offset = avatar.getAbsoluteJointTranslationInObjectFrame(headIndex).y / 2;
+ }
+
+ // 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-populateNearbyUserList if anything added or removed, but not for now.
+ HighlightedEntity.updateOverlays();
+}
+function removeOverlays() {
+ selectedIds = [];
+ lastHoveringId = 0;
+ HighlightedEntity.clearOverlays();
+ ExtendedOverlay.some(function (overlay) {
+ overlay.deleteOverlay();
+ });
+}
+
+//
+// Clicks.
+//
+function handleClick(pickRay) {
+ ExtendedOverlay.applyPickRay(pickRay, function (overlay) {
+ // Don't select directly. Tell qml, who will give us back a list of ids.
+ var message = {method: 'select', params: [[overlay.key], !overlay.selected, false]};
+ sendToQml(message);
+ return true;
+ });
+}
+function handleMouseEvent(mousePressEvent) { // handleClick if we get one.
+ if (!mousePressEvent.isLeftButton) {
+ return;
+ }
+ handleClick(Camera.computePickRay(mousePressEvent.x, mousePressEvent.y));
+}
+function handleMouseMove(pickRay) { // given the pickRay, just do the hover logic
+ ExtendedOverlay.applyPickRay(pickRay, function (overlay) {
+ overlay.hover(true);
+ }, function () {
+ ExtendedOverlay.unHover();
+ });
+}
+
+// handy global to keep track of which hand is the mouse (if any)
+var currentHandPressed = 0;
+var TRIGGER_CLICK_THRESHOLD = 0.85;
+var TRIGGER_PRESS_THRESHOLD = 0.05;
+
+function handleMouseMoveEvent(event) { // find out which overlay (if any) is over the mouse position
+ var pickRay;
+ if (HMD.active) {
+ if (currentHandPressed !== 0) {
+ pickRay = controllerComputePickRay(currentHandPressed);
+ } else {
+ // nothing should hover, so
+ ExtendedOverlay.unHover();
+ return;
+ }
+ } else {
+ pickRay = Camera.computePickRay(event.x, event.y);
+ }
+ handleMouseMove(pickRay);
+}
+function handleTriggerPressed(hand, value) {
+ // The idea is if you press one trigger, it is the one
+ // we will consider the mouse. Even if the other is pressed,
+ // we ignore it until this one is no longer pressed.
+ var isPressed = value > TRIGGER_PRESS_THRESHOLD;
+ if (currentHandPressed === 0) {
+ currentHandPressed = isPressed ? hand : 0;
+ return;
+ }
+ if (currentHandPressed === hand) {
+ currentHandPressed = isPressed ? hand : 0;
+ return;
+ }
+ // otherwise, the other hand is still triggered
+ // so do nothing.
+}
+
+// We get mouseMoveEvents from the handControllers, via handControllerPointer.
+// But we don't get mousePressEvents.
+var triggerMapping = Controller.newMapping(Script.resolvePath('') + '-click');
+var triggerPressMapping = Controller.newMapping(Script.resolvePath('') + '-press');
+function controllerComputePickRay(hand) {
+ var controllerPose = getControllerWorldLocation(hand, true);
+ if (controllerPose.valid) {
+ return { origin: controllerPose.position, direction: Quat.getUp(controllerPose.orientation) };
+ }
+}
+function makeClickHandler(hand) {
+ return function (clicked) {
+ if (clicked > TRIGGER_CLICK_THRESHOLD) {
+ var pickRay = controllerComputePickRay(hand);
+ handleClick(pickRay);
+ }
+ };
+}
+function makePressHandler(hand) {
+ return function (value) {
+ handleTriggerPressed(hand, value);
+ };
+}
+triggerMapping.from(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));
+//
+// Manage the connection between the button and the window.
+//
+var button;
+var buttonName = "AvatarApp";
+var tablet = null;
+
+function startup() {
+ tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system");
+ button = tablet.addButton({
+ text: buttonName,
+ icon: "icons/tablet-icons/people-i.svg",
+ activeIcon: "icons/tablet-icons/people-a.svg",
+ sortOrder: 7
+ });
+ button.clicked.connect(onTabletButtonClicked);
+ tablet.screenChanged.connect(onTabletScreenChanged);
+ Window.domainChanged.connect(clearLocalQMLDataAndClosePAL);
+ Window.domainConnectionRefused.connect(clearLocalQMLDataAndClosePAL);
+ Messages.subscribe(CHANNEL);
+ Messages.messageReceived.connect(receiveMessage);
+ Users.avatarDisconnected.connect(avatarDisconnected);
+ AvatarList.avatarAddedEvent.connect(avatarAdded);
+ AvatarList.avatarRemovedEvent.connect(avatarRemoved);
+ AvatarList.avatarSessionChangedEvent.connect(avatarSessionChanged);
+}
+
+startup();
+
+var isWired = false;
+var audioTimer;
+var AUDIO_LEVEL_UPDATE_INTERVAL_MS = 100; // 10hz for now (change this and change the AVERAGING_RATIO too)
+var AUDIO_LEVEL_CONSERVED_UPDATE_INTERVAL_MS = 300;
+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);
+ tablet.tabletShownChanged.disconnect(tabletVisibilityChanged);
+ Users.usernameFromIDReply.disconnect(usernameFromIDReply);
+ isWired = false;
+ ContextOverlay.enabled = true
+ }
+ if (audioTimer) {
+ Script.clearInterval(audioTimer);
+ }
+ triggerMapping.disable(); // It's ok if we disable twice.
+ triggerPressMapping.disable(); // see above
+ removeOverlays();
+ Users.requestsDomainListData = false;
+}
+
+function tabletVisibilityChanged() {
+ if (!tablet.tabletShown) {
+ ContextOverlay.enabled = true;
+ tablet.gotoHomeScreen();
+ }
+}
+
+var onPalScreen = false;
+
+function onTabletButtonClicked() {
+ if (onPalScreen) {
+ // for toolbar-mode: go back to home screen, this will close the window.
+ tablet.gotoHomeScreen();
+ ContextOverlay.enabled = true;
+ } else {
+ ContextOverlay.enabled = false;
+ tablet.loadQMLSource(PAL_QML_SOURCE);
+ tablet.tabletShownChanged.connect(tabletVisibilityChanged);
+ Users.requestsDomainListData = true;
+ populateNearbyUserList();
+ isWired = true;
+ Script.update.connect(updateOverlays);
+ Controller.mousePressEvent.connect(handleMouseEvent);
+ Controller.mouseMoveEvent.connect(handleMouseMoveEvent);
+ Users.usernameFromIDReply.connect(usernameFromIDReply);
+ triggerMapping.enable();
+ triggerPressMapping.enable();
+ audioTimer = createAudioInterval(conserveResources ? AUDIO_LEVEL_CONSERVED_UPDATE_INTERVAL_MS : AUDIO_LEVEL_UPDATE_INTERVAL_MS);
+ }
+}
+var hasEventBridge = false;
+function wireEventBridge(on) {
+ if (on) {
+ if (!hasEventBridge) {
+ tablet.fromQml.connect(fromQml);
+ hasEventBridge = true;
+ }
+ } else {
+ if (hasEventBridge) {
+ tablet.fromQml.disconnect(fromQml);
+ hasEventBridge = false;
+ }
+ }
+}
+
+function onTabletScreenChanged(type, url) {
+ onPalScreen = (type === "QML" && url === PAL_QML_SOURCE);
+ wireEventBridge(onPalScreen);
+ // for toolbar mode: change button to active when window is first openend, false otherwise.
+ button.editProperties({isActive: onPalScreen});
+
+ // disable sphere overlays when not on pal screen.
+ if (!onPalScreen) {
+ off();
+ }
+}
+
+//
+// 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':
+ sendToQml(message); // Accepts objects, not just strings.
+ break;
+ default:
+ print('Unrecognized PAL message', messageString);
+ }
+}
+
+var AVERAGING_RATIO = 0.05;
+var LOUDNESS_FLOOR = 11.0;
+var LOUDNESS_SCALE = 2.8 / 5.0;
+var LOG2 = Math.log(2.0);
+var AUDIO_PEAK_DECAY = 0.02;
+var myData = {}; // we're not includied in ExtendedOverlay.get.
+
+function scaleAudio(val) {
+ var audioLevel = 0.0;
+ if (val <= LOUDNESS_FLOOR) {
+ audioLevel = val / LOUDNESS_FLOOR * LOUDNESS_SCALE;
+ } else {
+ audioLevel = (val - (LOUDNESS_FLOOR - 1)) * LOUDNESS_SCALE;
+ }
+ if (audioLevel > 1.0) {
+ audioLevel = 1;
+ }
+ return audioLevel;
+}
+
+function 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 avgAudioLevel = 0.0;
+ var data = id ? ExtendedOverlay.get(id) : myData;
+ if (data) {
+
+ // we will do exponential moving average by taking some the last loudness and averaging
+ data.accumulatedLevel = AVERAGING_RATIO * (data.accumulatedLevel || 0) + (1 - AVERAGING_RATIO) * (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).
+ audioLevel = scaleAudio(Math.log(data.accumulatedLevel + 1) / LOG2);
+
+ // decay avgAudioLevel
+ avgAudioLevel = Math.max((1 - AUDIO_PEAK_DECAY) * (data.avgAudioLevel || 0), audioLevel);
+
+ data.avgAudioLevel = avgAudioLevel;
+ data.audioLevel = audioLevel;
+
+ // now scale for the gain. Also, asked to boost the low end, so one simple way is
+ // to take sqrt of the value. Lets try that, see how it feels.
+ avgAudioLevel = Math.min(1.0, Math.sqrt(avgAudioLevel * (sessionGains[id] || 0.75)));
+ }
+ return [audioLevel, avgAudioLevel];
+}
+
+function createAudioInterval(interval) {
+ // we will update the audioLevels periodically
+ // TODO: tune for efficiency - expecially with large numbers of avatars
+ return Script.setInterval(function () {
+ var param = {};
+ AvatarList.getAvatarIdentifiers().forEach(function (id) {
+ var level = getAudioLevel(id),
+ userId = id || 0; // qml didn't like an object with null/empty string for a key, so...
+ param[userId] = level;
+ });
+ sendToQml({method: 'updateAudioLevel', params: param});
+ }, interval);
+}
+
+function avatarDisconnected(nodeID) {
+ // remove from the pal list
+ sendToQml({method: 'avatarDisconnected', params: [nodeID]});
+}
+
+function clearLocalQMLDataAndClosePAL() {
+ sendToQml({ method: 'clearLocalQMLData' });
+ if (onPalScreen) {
+ ContextOverlay.enabled = true;
+ tablet.gotoHomeScreen();
+ }
+}
+
+function avatarAdded(avatarID) {
+ sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarAdded'] });
+}
+
+function avatarRemoved(avatarID) {
+ sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarRemoved'] });
+}
+
+function avatarSessionChanged(avatarID) {
+ sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarSessionChanged'] });
+}
+
+function shutdown() {
+ if (onPalScreen) {
+ tablet.gotoHomeScreen();
+ }
+ button.clicked.disconnect(onTabletButtonClicked);
+ tablet.removeButton(button);
+ tablet.screenChanged.disconnect(onTabletScreenChanged);
+ Window.domainChanged.disconnect(clearLocalQMLDataAndClosePAL);
+ Window.domainConnectionRefused.disconnect(clearLocalQMLDataAndClosePAL);
+ Messages.subscribe(CHANNEL);
+ Messages.messageReceived.disconnect(receiveMessage);
+ Users.avatarDisconnected.disconnect(avatarDisconnected);
+ AvatarList.avatarAddedEvent.disconnect(avatarAdded);
+ AvatarList.avatarRemovedEvent.disconnect(avatarRemoved);
+ AvatarList.avatarSessionChangedEvent.disconnect(avatarSessionChanged);
+ off();
+}
+
+//
+// Cleanup.
+//
+Script.scriptEnding.connect(shutdown);
+
+}()); // END LOCAL_SCOPE