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