diff --git a/assignment-client/src/AssignmentClient.cpp b/assignment-client/src/AssignmentClient.cpp index eb0ffefe47..0869329d68 100644 --- a/assignment-client/src/AssignmentClient.cpp +++ b/assignment-client/src/AssignmentClient.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include "AssignmentFactory.h" #include "AssignmentDynamicFactory.h" @@ -66,6 +67,7 @@ AssignmentClient::AssignmentClient(Assignment::Type requestAssignmentType, QStri DependencyManager::registerInheritance(); auto dynamicFactory = DependencyManager::set(); DependencyManager::set(); + DependencyManager::set(); // setup a thread for the NodeList and its PacketReceiver QThread* nodeThread = new QThread(this); diff --git a/interface/resources/icons/loader-red-countdown-ring.gif b/interface/resources/icons/loader-red-countdown-ring.gif new file mode 100644 index 0000000000..eb15b9aedd Binary files /dev/null and b/interface/resources/icons/loader-red-countdown-ring.gif differ diff --git a/interface/resources/icons/tablet-icons/avatar-record-a.svg b/interface/resources/icons/tablet-icons/avatar-record-a.svg new file mode 100644 index 0000000000..7358bdb0db --- /dev/null +++ b/interface/resources/icons/tablet-icons/avatar-record-a.svg @@ -0,0 +1,109 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/interface/resources/icons/tablet-icons/avatar-record-i.svg b/interface/resources/icons/tablet-icons/avatar-record-i.svg new file mode 100644 index 0000000000..5e139a6497 --- /dev/null +++ b/interface/resources/icons/tablet-icons/avatar-record-i.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/interface/resources/qml/desktop/Desktop.qml b/interface/resources/qml/desktop/Desktop.qml index d8aedf6666..42db16aa72 100644 --- a/interface/resources/qml/desktop/Desktop.qml +++ b/interface/resources/qml/desktop/Desktop.qml @@ -466,6 +466,11 @@ FocusScope { return fileDialogBuilder.createObject(desktop, properties); } + Component { id: assetDialogBuilder; AssetDialog { } } + function assetDialog(properties) { + return assetDialogBuilder.createObject(desktop, properties); + } + function unfocusWindows() { // First find the active focus item, and unfocus it, all the way // up the parent chain to the window diff --git a/interface/resources/qml/dialogs/AssetDialog.qml b/interface/resources/qml/dialogs/AssetDialog.qml new file mode 100644 index 0000000000..8d19d38efb --- /dev/null +++ b/interface/resources/qml/dialogs/AssetDialog.qml @@ -0,0 +1,58 @@ +// +// AssetDialog.qml +// +// Created by David Rowe on 18 Apr 2017 +// Copyright 2017 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.Controls 1.4 +import Qt.labs.settings 1.0 + +import "../styles-uit" +import "../windows" + +import "assetDialog" + +ModalWindow { + id: root + resizable: true + implicitWidth: 480 + implicitHeight: 360 + + minSize: Qt.vector2d(360, 240) + draggable: true + + Settings { + category: "AssetDialog" + property alias width: root.width + property alias height: root.height + property alias x: root.x + property alias y: root.y + } + + // Set from OffscreenUi::assetDialog(). + property alias caption: root.title + property alias dir: assetDialogContent.dir + property alias filter: assetDialogContent.filter + property alias options: assetDialogContent.options + + // Dialog results. + signal selectedAsset(var asset); + signal canceled(); + + property int titleWidth: 0 // For ModalFrame. + + HifiConstants { id: hifi } + + AssetDialogContent { + id: assetDialogContent + + width: pane.width + height: pane.height + anchors.margins: 0 + } +} diff --git a/interface/resources/qml/dialogs/TabletAssetDialog.qml b/interface/resources/qml/dialogs/TabletAssetDialog.qml new file mode 100644 index 0000000000..016deec094 --- /dev/null +++ b/interface/resources/qml/dialogs/TabletAssetDialog.qml @@ -0,0 +1,53 @@ +// +// TabletAssetDialog.qml +// +// Created by David Rowe on 18 Apr 2017 +// Copyright 2017 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.Controls 1.4 + +import "../styles-uit" +import "../windows" + +import "assetDialog" + +TabletModalWindow { + id: root + anchors.fill: parent + width: parent.width + height: parent.height + + // Set from OffscreenUi::assetDialog(). + property alias caption: root.title + property alias dir: assetDialogContent.dir + property alias filter: assetDialogContent.filter + property alias options: assetDialogContent.options + + // Dialog results. + signal selectedAsset(var asset); + signal canceled(); + + property int titleWidth: 0 // For TabletModalFrame. + + TabletModalFrame { + id: frame + anchors.fill: parent + + AssetDialogContent { + id: assetDialogContent + singleClickNavigate: true + width: parent.width - 12 + height: parent.height - frame.frameMarginTop - 12 + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + topMargin: parent.height - height - 6 + } + } + } +} diff --git a/interface/resources/qml/dialogs/assetDialog/AssetDialogContent.qml b/interface/resources/qml/dialogs/assetDialog/AssetDialogContent.qml new file mode 100644 index 0000000000..8c0501e3b4 --- /dev/null +++ b/interface/resources/qml/dialogs/assetDialog/AssetDialogContent.qml @@ -0,0 +1,536 @@ +// +// AssetDialogContent.qml +// +// Created by David Rowe on 19 Apr 2017 +// Copyright 2017 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.Controls 1.4 + +import "../../controls-uit" +import "../../styles-uit" + +import "../fileDialog" + +Item { + // Set from OffscreenUi::assetDialog() + property alias dir: assetTableModel.folder + property alias filter: selectionType.filtersString // FIXME: Currently only supports simple filters, "*.xxx". + property int options // Not used. + + property bool selectDirectory: false + + // Not implemented. + //property bool saveDialog: false; + //property bool multiSelect: false; + + property bool singleClickNavigate: false + + HifiConstants { id: hifi } + + Component.onCompleted: { + homeButton.destination = dir; + + if (selectDirectory) { + d.currentSelectionIsFolder = true; + d.currentSelectionPath = assetTableModel.folder; + } + + assetTableView.forceActiveFocus(); + } + + Item { + id: assetDialogItem + anchors.fill: parent + clip: true + + MouseArea { + // Clear selection when click on internal unused area. + anchors.fill: parent + drag.target: root + onClicked: { + d.clearSelection(); + frame.forceActiveFocus(); + assetTableView.forceActiveFocus(); + } + } + + Row { + id: navControls + anchors { + top: parent.top + topMargin: hifi.dimensions.contentMargin.y + left: parent.left + } + spacing: hifi.dimensions.contentSpacing.x + + GlyphButton { + id: upButton + glyph: hifi.glyphs.levelUp + width: height + size: 30 + enabled: assetTableModel.parentFolder !== "" + onClicked: d.navigateUp(); + } + + GlyphButton { + id: homeButton + property string destination: "" + glyph: hifi.glyphs.home + size: 28 + width: height + enabled: destination !== "" + //onClicked: d.navigateHome(); + onClicked: assetTableModel.folder = destination; + } + } + + ComboBox { + id: pathSelector + anchors { + top: parent.top + topMargin: hifi.dimensions.contentMargin.y + left: navControls.right + leftMargin: hifi.dimensions.contentSpacing.x + right: parent.right + } + z: 10 + + property string lastValidFolder: assetTableModel.folder + + function calculatePathChoices(folder) { + var folders = folder.split("/"), + choices = [], + i, length; + + if (folders[folders.length - 1] === "") { + folders.pop(); + } + + choices.push(folders[0]); + + for (i = 1, length = folders.length; i < length; i++) { + choices.push(choices[i - 1] + "/" + folders[i]); + } + + if (folders[0] === "") { + choices[0] = "/"; + } + + choices.reverse(); + + if (choices.length > 0) { + pathSelector.model = choices; + } + } + + onLastValidFolderChanged: { + var folder = lastValidFolder; + calculatePathChoices(folder); + } + + onCurrentTextChanged: { + var folder = currentText; + + if (folder !== "/") { + folder += "/"; + } + + if (folder !== assetTableModel.folder) { + if (root.selectDirectory) { + currentSelection.text = currentText; + d.currentSelectionPath = currentText; + } + assetTableModel.folder = folder; + assetTableView.forceActiveFocus(); + } + } + } + + QtObject { + id: d + + property string currentSelectionPath + property bool currentSelectionIsFolder + property var tableViewConnection: Connections { target: assetTableView; onCurrentRowChanged: d.update(); } + + function update() { + var row = assetTableView.currentRow; + + if (row === -1) { + if (!root.selectDirectory) { + currentSelection.text = ""; + currentSelectionIsFolder = false; + } + return; + } + + var rowInfo = assetTableModel.get(row); + currentSelectionPath = rowInfo.filePath; + currentSelectionIsFolder = rowInfo.fileIsDir; + if (root.selectDirectory || !currentSelectionIsFolder) { + currentSelection.text = currentSelectionPath; + } else { + currentSelection.text = ""; + } + } + + function navigateUp() { + if (assetTableModel.parentFolder !== "") { + assetTableModel.folder = assetTableModel.parentFolder; + return true; + } + return false; + } + + function navigateHome() { + assetTableModel.folder = homeButton.destination; + return true; + } + + function clearSelection() { + assetTableView.selection.clear(); + assetTableView.currentRow = -1; + update(); + } + } + + ListModel { + id: assetTableModel + + property string folder + property string parentFolder: "" + readonly property string rootFolder: "/" + + onFolderChanged: { + parentFolder = calculateParentFolder(); + update(); + } + + function calculateParentFolder() { + if (folder !== "/") { + return folder.slice(0, folder.slice(0, -1).lastIndexOf("/") + 1); + } + return ""; + } + + function isFolder(row) { + if (row === -1) { + return false; + } + return get(row).fileIsDir; + } + + function onGetAllMappings(error, map) { + var mappings, + fileTypeFilter, + index, + path, + fileName, + fileType, + fileIsDir, + isValid, + subDirectory, + subDirectories = [], + fileNameSort, + rows = 0, + lower, + middle, + upper, + i, + length; + + clear(); + + if (error === "") { + mappings = Object.keys(map); + fileTypeFilter = filter.replace("*", "").toLowerCase(); + + for (i = 0, length = mappings.length; i < length; i++) { + index = mappings[i].lastIndexOf("/"); + + path = mappings[i].slice(0, mappings[i].lastIndexOf("/") + 1); + fileName = mappings[i].slice(path.length); + fileType = fileName.slice(fileName.lastIndexOf(".")); + fileIsDir = false; + isValid = false; + + if (fileType.toLowerCase() === fileTypeFilter) { + if (path === folder) { + isValid = !selectDirectory; + } else if (path.length > folder.length) { + subDirectory = path.slice(folder.length); + index = subDirectory.indexOf("/"); + if (index === subDirectory.lastIndexOf("/")) { + fileName = subDirectory.slice(0, index); + if (subDirectories.indexOf(fileName) === -1) { + fileIsDir = true; + isValid = true; + subDirectories.push(fileName); + } + } + } + } + + if (isValid) { + fileNameSort = (fileIsDir ? "*" : "") + fileName.toLowerCase(); + + lower = 0; + upper = rows; + while (lower < upper) { + middle = Math.floor((lower + upper) / 2); + var lessThan; + if (fileNameSort < get(middle)["fileNameSort"]) { + lessThan = true; + upper = middle; + } else { + lessThan = false; + lower = middle + 1; + } + } + + insert(lower, { + fileName: fileName, + filePath: path + (fileIsDir ? "" : fileName), + fileIsDir: fileIsDir, + fileNameSort: fileNameSort + }); + + rows++; + } + } + + } else { + console.log("Error getting mappings from Asset Server"); + } + } + + function update() { + d.clearSelection(); + clear(); + Assets.getAllMappings(onGetAllMappings); + } + } + + Table { + id: assetTableView + colorScheme: hifi.colorSchemes.light + anchors { + top: navControls.bottom + topMargin: hifi.dimensions.contentSpacing.y + left: parent.left + right: parent.right + bottom: currentSelection.top + bottomMargin: hifi.dimensions.contentSpacing.y + currentSelection.controlHeight - currentSelection.height + } + + model: assetTableModel + + focus: true + + onClicked: { + if (singleClickNavigate) { + navigateToRow(row); + } + } + + onDoubleClicked: navigateToRow(row); + Keys.onReturnPressed: navigateToCurrentRow(); + Keys.onEnterPressed: navigateToCurrentRow(); + + itemDelegate: Item { + clip: true + + FontLoader { id: firaSansSemiBold; source: "../../../fonts/FiraSans-SemiBold.ttf"; } + FontLoader { id: firaSansRegular; source: "../../../fonts/FiraSans-Regular.ttf"; } + + FiraSansSemiBold { + text: styleData.value + elide: styleData.elideMode + anchors { + left: parent.left + leftMargin: hifi.dimensions.tablePadding + right: parent.right + rightMargin: hifi.dimensions.tablePadding + verticalCenter: parent.verticalCenter + } + size: hifi.fontSizes.tableText + color: hifi.colors.baseGrayHighlight + font.family: (styleData.row !== -1 && assetTableView.model.get(styleData.row).fileIsDir) + ? firaSansSemiBold.name : firaSansRegular.name + } + } + + TableViewColumn { + id: fileNameColumn + role: "fileName" + title: "Name" + width: assetTableView.width + movable: false + resizable: false + } + + function navigateToRow(row) { + currentRow = row; + navigateToCurrentRow(); + } + + function navigateToCurrentRow() { + if (model.isFolder(currentRow)) { + model.folder = model.get(currentRow).filePath; + } else { + okAction.trigger(); + } + } + + Timer { + id: prefixClearTimer + interval: 1000 + repeat: false + running: false + onTriggered: assetTableView.prefix = ""; + } + + property string prefix: "" + + function addToPrefix(event) { + if (!event.text || event.text === "") { + return false; + } + var newPrefix = prefix + event.text.toLowerCase(); + var matchedIndex = -1; + for (var i = 0; i < model.count; ++i) { + var name = model.get(i).fileName.toLowerCase(); + if (0 === name.indexOf(newPrefix)) { + matchedIndex = i; + break; + } + } + + if (matchedIndex !== -1) { + assetTableView.selection.clear(); + assetTableView.selection.select(matchedIndex); + assetTableView.currentRow = matchedIndex; + assetTableView.prefix = newPrefix; + } + prefixClearTimer.restart(); + return true; + } + + Keys.onPressed: { + switch (event.key) { + case Qt.Key_Backspace: + case Qt.Key_Tab: + case Qt.Key_Backtab: + event.accepted = false; + break; + + default: + if (addToPrefix(event)) { + event.accepted = true + } else { + event.accepted = false; + } + break; + } + } + } + + TextField { + id: currentSelection + label: selectDirectory ? "Directory:" : "File name:" + anchors { + left: parent.left + right: selectionType.visible ? selectionType.left: parent.right + rightMargin: selectionType.visible ? hifi.dimensions.contentSpacing.x : 0 + bottom: buttonRow.top + bottomMargin: hifi.dimensions.contentSpacing.y + } + readOnly: true + activeFocusOnTab: !readOnly + onActiveFocusChanged: if (activeFocus) { selectAll(); } + onAccepted: okAction.trigger(); + } + + FileTypeSelection { + id: selectionType + anchors { + top: currentSelection.top + left: buttonRow.left + right: parent.right + } + visible: !selectDirectory && filtersCount > 1 + KeyNavigation.left: assetTableView + KeyNavigation.right: openButton + } + + Action { + id: okAction + text: currentSelection.text && root.selectDirectory && assetTableView.currentRow === -1 ? "Choose" : "Open" + enabled: currentSelection.text || !root.selectDirectory && d.currentSelectionIsFolder ? true : false + onTriggered: { + if (!root.selectDirectory && !d.currentSelectionIsFolder + || root.selectDirectory && assetTableView.currentRow === -1) { + selectedAsset(d.currentSelectionPath); + root.destroy(); + } else { + assetTableView.navigateToCurrentRow(); + } + } + } + + Action { + id: cancelAction + text: "Cancel" + onTriggered: { + canceled(); + root.destroy(); + } + } + + Row { + id: buttonRow + anchors { + right: parent.right + bottom: parent.bottom + } + spacing: hifi.dimensions.contentSpacing.y + + Button { + id: openButton + color: hifi.buttons.blue + action: okAction + Keys.onReturnPressed: okAction.trigger() + KeyNavigation.up: selectionType + KeyNavigation.left: selectionType + KeyNavigation.right: cancelButton + } + + Button { + id: cancelButton + action: cancelAction + KeyNavigation.up: selectionType + KeyNavigation.left: openButton + KeyNavigation.right: assetTableView.contentItem + Keys.onReturnPressed: { canceled(); root.enabled = false } + } + } + } + + Keys.onPressed: { + switch (event.key) { + case Qt.Key_Backspace: + event.accepted = d.navigateUp(); + break; + + case Qt.Key_Home: + event.accepted = d.navigateHome(); + break; + + } + } +} diff --git a/interface/resources/qml/hifi/tablet/TabletRoot.qml b/interface/resources/qml/hifi/tablet/TabletRoot.qml index 446d4c91ff..31e6174563 100644 --- a/interface/resources/qml/hifi/tablet/TabletRoot.qml +++ b/interface/resources/qml/hifi/tablet/TabletRoot.qml @@ -44,6 +44,12 @@ Item { return openModal; } + Component { id: assetDialogBuilder; TabletAssetDialog { } } + function assetDialog(properties) { + openModal = assetDialogBuilder.createObject(tabletRoot, properties); + return openModal; + } + function setMenuProperties(rootMenu, subMenu) { tabletRoot.rootMenu = rootMenu; tabletRoot.subMenu = subMenu; diff --git a/interface/src/scripting/WindowScriptingInterface.cpp b/interface/src/scripting/WindowScriptingInterface.cpp index 39c2f2e402..39cf99f349 100644 --- a/interface/src/scripting/WindowScriptingInterface.cpp +++ b/interface/src/scripting/WindowScriptingInterface.cpp @@ -28,6 +28,7 @@ static const QString DESKTOP_LOCATION = QStandardPaths::writableLocation(QStandardPaths::DesktopLocation); static const QString LAST_BROWSE_LOCATION_SETTING = "LastBrowseLocation"; +static const QString LAST_BROWSE_ASSETS_LOCATION_SETTING = "LastBrowseAssetsLocation"; QScriptValue CustomPromptResultToScriptValue(QScriptEngine* engine, const CustomPromptResult& result) { @@ -149,6 +150,15 @@ void WindowScriptingInterface::setPreviousBrowseLocation(const QString& location Setting::Handle(LAST_BROWSE_LOCATION_SETTING).set(location); } +QString WindowScriptingInterface::getPreviousBrowseAssetLocation() const { + QString ASSETS_ROOT_PATH = "/"; + return Setting::Handle(LAST_BROWSE_ASSETS_LOCATION_SETTING, ASSETS_ROOT_PATH).get(); +} + +void WindowScriptingInterface::setPreviousBrowseAssetLocation(const QString& location) { + Setting::Handle(LAST_BROWSE_ASSETS_LOCATION_SETTING).set(location); +} + /// Makes sure that the reticle is visible, use this in blocking forms that require a reticle and /// might be in same thread as a script that sets the reticle to invisible void WindowScriptingInterface::ensureReticleVisible() const { @@ -202,6 +212,31 @@ QScriptValue WindowScriptingInterface::save(const QString& title, const QString& return result.isEmpty() ? QScriptValue::NullValue : QScriptValue(result); } +/// Display a select asset dialog that lets the user select an asset from the Asset Server. If `directory` is an invalid +/// directory the browser will start at the root directory. +/// \param const QString& title title of the window +/// \param const QString& directory directory to start the asset browser at +/// \param const QString& nameFilter filter to filter asset names by - see `QFileDialog` +/// \return QScriptValue asset path as a string if one was selected, otherwise `QScriptValue::NullValue` +QScriptValue WindowScriptingInterface::browseAssets(const QString& title, const QString& directory, const QString& nameFilter) { + ensureReticleVisible(); + QString path = directory; + if (path.isEmpty()) { + path = getPreviousBrowseAssetLocation(); + } + if (path.left(1) != "/") { + path = "/" + path; + } + if (path.right(1) != "/") { + path = path + "/"; + } + QString result = OffscreenUi::getOpenAssetName(nullptr, title, path, nameFilter); + if (!result.isEmpty()) { + setPreviousBrowseAssetLocation(QFileInfo(result).absolutePath()); + } + return result.isEmpty() ? QScriptValue::NullValue : QScriptValue(result); +} + void WindowScriptingInterface::showAssetServer(const QString& upload) { QMetaObject::invokeMethod(qApp, "showAssetServerWidget", Qt::QueuedConnection, Q_ARG(QString, upload)); } diff --git a/interface/src/scripting/WindowScriptingInterface.h b/interface/src/scripting/WindowScriptingInterface.h index d4ff278fea..6934dea0af 100644 --- a/interface/src/scripting/WindowScriptingInterface.h +++ b/interface/src/scripting/WindowScriptingInterface.h @@ -53,6 +53,7 @@ public slots: CustomPromptResult customPrompt(const QVariant& config); QScriptValue browse(const QString& title = "", const QString& directory = "", const QString& nameFilter = ""); QScriptValue save(const QString& title = "", const QString& directory = "", const QString& nameFilter = ""); + QScriptValue browseAssets(const QString& title = "", const QString& directory = "", const QString& nameFilter = ""); void showAssetServer(const QString& upload = ""); void copyToClipboard(const QString& text); void takeSnapshot(bool notify = true, bool includeAnimated = false, float aspectRatio = 0.0f); @@ -88,6 +89,9 @@ private: QString getPreviousBrowseLocation() const; void setPreviousBrowseLocation(const QString& location); + QString getPreviousBrowseAssetLocation() const; + void setPreviousBrowseAssetLocation(const QString& location); + void ensureReticleVisible() const; int createMessageBox(QString title, QString text, int buttons, int defaultButton); diff --git a/libraries/networking/src/UserActivityLoggerScriptingInterface.cpp b/libraries/networking/src/UserActivityLoggerScriptingInterface.cpp index 9c29e87f16..ff69363570 100644 --- a/libraries/networking/src/UserActivityLoggerScriptingInterface.cpp +++ b/libraries/networking/src/UserActivityLoggerScriptingInterface.cpp @@ -13,28 +13,28 @@ #include "UserActivityLogger.h" void UserActivityLoggerScriptingInterface::enabledEdit() { - logAction("enabled_edit"); + doLogAction("enabled_edit"); } void UserActivityLoggerScriptingInterface::openedTablet(bool visibleToOthers) { - logAction("opened_tablet", { { "visible_to_others", visibleToOthers } }); + doLogAction("opened_tablet", { { "visible_to_others", visibleToOthers } }); } void UserActivityLoggerScriptingInterface::closedTablet() { - logAction("closed_tablet"); + doLogAction("closed_tablet"); } void UserActivityLoggerScriptingInterface::openedMarketplace() { - logAction("opened_marketplace"); + doLogAction("opened_marketplace"); } void UserActivityLoggerScriptingInterface::toggledAway(bool isAway) { - logAction("toggled_away", { { "is_away", isAway } }); + doLogAction("toggled_away", { { "is_away", isAway } }); } void UserActivityLoggerScriptingInterface::tutorialProgress( QString stepName, int stepNumber, float secondsToComplete, float tutorialElapsedTime, QString tutorialRunID, int tutorialVersion, QString controllerType) { - logAction("tutorial_progress", { + doLogAction("tutorial_progress", { { "tutorial_run_id", tutorialRunID }, { "tutorial_version", tutorialVersion }, { "step", stepName }, @@ -52,11 +52,11 @@ void UserActivityLoggerScriptingInterface::palAction(QString action, QString tar if (target.length() > 0) { payload["target"] = target; } - logAction("pal_activity", payload); + doLogAction("pal_activity", payload); } void UserActivityLoggerScriptingInterface::palOpened(float secondsOpened) { - logAction("pal_opened", { + doLogAction("pal_opened", { { "seconds_opened", secondsOpened } }); } @@ -68,10 +68,14 @@ void UserActivityLoggerScriptingInterface::makeUserConnection(QString otherID, b if (detailsString.length() > 0) { payload["details"] = detailsString; } - logAction("makeUserConnection", payload); + doLogAction("makeUserConnection", payload); } -void UserActivityLoggerScriptingInterface::logAction(QString action, QJsonObject details) { +void UserActivityLoggerScriptingInterface::logAction(QString action, QVariantMap details) { + doLogAction(action, QJsonObject::fromVariantMap(details)); +} + +void UserActivityLoggerScriptingInterface::doLogAction(QString action, QJsonObject details) { QMetaObject::invokeMethod(&UserActivityLogger::getInstance(), "logAction", Q_ARG(QString, action), Q_ARG(QJsonObject, details)); diff --git a/libraries/networking/src/UserActivityLoggerScriptingInterface.h b/libraries/networking/src/UserActivityLoggerScriptingInterface.h index b68c7beb95..b141e930f2 100644 --- a/libraries/networking/src/UserActivityLoggerScriptingInterface.h +++ b/libraries/networking/src/UserActivityLoggerScriptingInterface.h @@ -29,9 +29,10 @@ public: float tutorialElapsedTime, QString tutorialRunID = "", int tutorialVersion = 0, QString controllerType = ""); Q_INVOKABLE void palAction(QString action, QString target); Q_INVOKABLE void palOpened(float secondsOpen); - Q_INVOKABLE void makeUserConnection(QString otherUser, bool success, QString details=""); + Q_INVOKABLE void makeUserConnection(QString otherUser, bool success, QString details = ""); + Q_INVOKABLE void logAction(QString action, QVariantMap details = QVariantMap{}); private: - void logAction(QString action, QJsonObject details = {}); + void doLogAction(QString action, QJsonObject details = {}); }; #endif // hifi_UserActivityLoggerScriptingInterface_h diff --git a/libraries/script-engine/src/AssetScriptingInterface.cpp b/libraries/script-engine/src/AssetScriptingInterface.cpp index 00fa1f3ba5..65259987c4 100644 --- a/libraries/script-engine/src/AssetScriptingInterface.cpp +++ b/libraries/script-engine/src/AssetScriptingInterface.cpp @@ -44,7 +44,8 @@ void AssetScriptingInterface::setMapping(QString path, QString hash, QScriptValu QObject::connect(setMappingRequest, &SetMappingRequest::finished, this, [this, callback](SetMappingRequest* request) mutable { if (callback.isFunction()) { - QScriptValueList args { }; + QString error = request->getErrorString(); + QScriptValueList args { error }; callback.call(_engine->currentContext()->thisObject(), args); } request->deleteLater(); diff --git a/libraries/script-engine/src/AssetScriptingInterface.h b/libraries/script-engine/src/AssetScriptingInterface.h index d8bc319256..0238329b73 100644 --- a/libraries/script-engine/src/AssetScriptingInterface.h +++ b/libraries/script-engine/src/AssetScriptingInterface.h @@ -72,6 +72,7 @@ public: /**jsdoc * Called when setMapping is complete * @callback Assets~setMappingCallback + * @param {string} error */ Q_INVOKABLE void setMapping(QString path, QString hash, QScriptValue callback); diff --git a/libraries/script-engine/src/RecordingScriptingInterface.cpp b/libraries/script-engine/src/RecordingScriptingInterface.cpp index 36de1c1ef7..98838441d2 100644 --- a/libraries/script-engine/src/RecordingScriptingInterface.cpp +++ b/libraries/script-engine/src/RecordingScriptingInterface.cpp @@ -210,9 +210,11 @@ bool RecordingScriptingInterface::saveRecordingToAsset(QScriptValue getClipAtpUr } if (QThread::currentThread() != thread()) { + bool result; QMetaObject::invokeMethod(this, "saveRecordingToAsset", Qt::BlockingQueuedConnection, + Q_RETURN_ARG(bool, result), Q_ARG(QScriptValue, getClipAtpUrl)); - return false; + return result; } if (!_lastClip) { diff --git a/libraries/script-engine/src/ScriptEngine.cpp b/libraries/script-engine/src/ScriptEngine.cpp index 067c7c1412..93a98e9701 100644 --- a/libraries/script-engine/src/ScriptEngine.cpp +++ b/libraries/script-engine/src/ScriptEngine.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -678,6 +679,8 @@ void ScriptEngine::init() { registerGlobalObject("Model", new ModelScriptingInterface(this)); qScriptRegisterMetaType(this, meshToScriptValue, meshFromScriptValue); qScriptRegisterMetaType(this, meshesToScriptValue, meshesFromScriptValue); + + registerGlobalObject("UserActivityLogger", DependencyManager::get().data()); } void ScriptEngine::registerValue(const QString& valueName, QScriptValue value) { diff --git a/libraries/ui/src/OffscreenUi.cpp b/libraries/ui/src/OffscreenUi.cpp index 2a8f3ec9d5..84812b4f60 100644 --- a/libraries/ui/src/OffscreenUi.cpp +++ b/libraries/ui/src/OffscreenUi.cpp @@ -716,6 +716,86 @@ QString OffscreenUi::getExistingDirectory(void* ignored, const QString &caption, return DependencyManager::get()->existingDirectoryDialog(caption, dir, filter, selectedFilter, options); } +class AssetDialogListener : public ModalDialogListener { + // ATP equivalent of FileDialogListener. + Q_OBJECT + + friend class OffscreenUi; + AssetDialogListener(QQuickItem* messageBox) : ModalDialogListener(messageBox) { + if (_finished) { + return; + } + connect(_dialog, SIGNAL(selectedAsset(QVariant)), this, SLOT(onSelectedAsset(QVariant))); + } + + private slots: + void onSelectedAsset(QVariant asset) { + _result = asset; + _finished = true; + disconnect(_dialog); + } +}; + + +QString OffscreenUi::assetDialog(const QVariantMap& properties) { + // ATP equivalent of fileDialog(). + QVariant buildDialogResult; + bool invokeResult; + auto tabletScriptingInterface = DependencyManager::get(); + TabletProxy* tablet = dynamic_cast(tabletScriptingInterface->getTablet("com.highfidelity.interface.tablet.system")); + if (tablet->getToolbarMode()) { + invokeResult = QMetaObject::invokeMethod(_desktop, "assetDialog", + Q_RETURN_ARG(QVariant, buildDialogResult), + Q_ARG(QVariant, QVariant::fromValue(properties))); + } else { + QQuickItem* tabletRoot = tablet->getTabletRoot(); + invokeResult = QMetaObject::invokeMethod(tabletRoot, "assetDialog", + Q_RETURN_ARG(QVariant, buildDialogResult), + Q_ARG(QVariant, QVariant::fromValue(properties))); + emit tabletScriptingInterface->tabletNotification(); + } + + if (!invokeResult) { + qWarning() << "Failed to create asset open dialog"; + return QString(); + } + + QVariant result = AssetDialogListener(qvariant_cast(buildDialogResult)).waitForResult(); + if (!result.isValid()) { + return QString(); + } + qCDebug(uiLogging) << result.toString(); + return result.toUrl().toString(); +} + +QString OffscreenUi::assetOpenDialog(const QString& caption, const QString& dir, const QString& filter, QString* selectedFilter, QFileDialog::Options options) { + // ATP equivalent of fileOpenDialog(). + if (QThread::currentThread() != thread()) { + QString result; + QMetaObject::invokeMethod(this, "assetOpenDialog", Qt::BlockingQueuedConnection, + Q_RETURN_ARG(QString, result), + Q_ARG(QString, caption), + Q_ARG(QString, dir), + Q_ARG(QString, filter), + Q_ARG(QString*, selectedFilter), + Q_ARG(QFileDialog::Options, options)); + return result; + } + + // FIXME support returning the selected filter... somehow? + QVariantMap map; + map.insert("caption", caption); + map.insert("dir", dir); + map.insert("filter", filter); + map.insert("options", static_cast(options)); + return assetDialog(map); +} + +QString OffscreenUi::getOpenAssetName(void* ignored, const QString &caption, const QString &dir, const QString &filter, QString *selectedFilter, QFileDialog::Options options) { + // ATP equivalent of getOpenFileName(). + return DependencyManager::get()->assetOpenDialog(caption, dir, filter, selectedFilter, options); +} + bool OffscreenUi::eventFilter(QObject* originalDestination, QEvent* event) { if (!filterEnabled(originalDestination, event)) { return false; diff --git a/libraries/ui/src/OffscreenUi.h b/libraries/ui/src/OffscreenUi.h index 5813d0bfd2..55fb8b2c3d 100644 --- a/libraries/ui/src/OffscreenUi.h +++ b/libraries/ui/src/OffscreenUi.h @@ -118,6 +118,8 @@ public: Q_INVOKABLE QString fileSaveDialog(const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); Q_INVOKABLE QString existingDirectoryDialog(const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); + Q_INVOKABLE QString assetOpenDialog(const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); + // Compatibility with QFileDialog::getOpenFileName static QString getOpenFileName(void* ignored, const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); // Compatibility with QFileDialog::getSaveFileName @@ -125,6 +127,8 @@ public: // Compatibility with QFileDialog::getExistingDirectory static QString getExistingDirectory(void* ignored, const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); + static QString getOpenAssetName(void* ignored, const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); + Q_INVOKABLE QVariant inputDialog(const Icon icon, const QString& title, const QString& label = QString(), const QVariant& current = QVariant()); Q_INVOKABLE QVariant customInputDialog(const Icon icon, const QString& title, const QVariantMap& config); QQuickItem* createInputDialog(const Icon icon, const QString& title, const QString& label, const QVariant& current); @@ -155,6 +159,7 @@ signals: private: QString fileDialog(const QVariantMap& properties); + QString assetDialog(const QVariantMap& properties); QQuickItem* _desktop { nullptr }; QQuickItem* _toolWindow { nullptr }; diff --git a/scripts/system/assets/sounds/countdown-tick.wav b/scripts/system/assets/sounds/countdown-tick.wav new file mode 100644 index 0000000000..015e1f642e Binary files /dev/null and b/scripts/system/assets/sounds/countdown-tick.wav differ diff --git a/scripts/system/assets/sounds/finish-recording.wav b/scripts/system/assets/sounds/finish-recording.wav new file mode 100644 index 0000000000..f224049f97 Binary files /dev/null and b/scripts/system/assets/sounds/finish-recording.wav differ diff --git a/scripts/system/assets/sounds/start-recording.wav b/scripts/system/assets/sounds/start-recording.wav new file mode 100644 index 0000000000..71c69f3372 Binary files /dev/null and b/scripts/system/assets/sounds/start-recording.wav differ diff --git a/scripts/system/html/css/record.css b/scripts/system/html/css/record.css new file mode 100644 index 0000000000..35751379b9 --- /dev/null +++ b/scripts/system/html/css/record.css @@ -0,0 +1,218 @@ +/* +// record.css +// +// Created by David Rowe on 5 Apr 2017. +// Copyright 2017 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 +*/ + + +body { + padding: 0; + overflow: hidden; +} + +.title { + padding-left: 21px; +} + +.title label { + font-size: 18px; + position: relative; + top: 12px; +} + + +#recordings { + height: 100%; + position: relative; + box-sizing: border-box; + padding: 51px 0 185px 0; + margin: 0 21px 0 21px; +} + +#recordings #table-container { + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: auto; + box-sizing: border-box; + border-left: 2px solid #575757; + border-right: 2px solid #575757; + background-color: #2e2e2e; +} + +#recordings table { + border: none; +} + +#recordings thead { + position: absolute; + top: 21px; + left: 0; + width: 100%; + box-sizing: border-box; + border: 2px solid #575757; + border-top-left-radius: 7px; + border-top-right-radius: 7px; + border-bottom: 1px solid #575757; + position: absolute; + word-wrap: nowrap; + white-space: nowrap; + overflow: hidden; +} + +#recordings table col#unload-column { + width: 100px; +} + +#recordings thead th:last-child { + width: 100px; +} + +#recordings table td { + text-overflow: ellipsis; +} + +#recordings table td:nth-child(2) { + text-align: center; +} + +#recordings tbody tr.filler td { + height: auto; + border-top: 1px solid #1c1c1c; +} + +#recordings-list input { + height: 22px; + width: 22px; + min-width: 22px; + font-size: 16px; + padding: 0 1px 0 0; +} + +#recordings tfoot { + position: absolute; + bottom: 159px; + left: 0; + width: 100%; + box-sizing: border-box; + border: 2px solid #575757; + border-bottom-left-radius: 7px; + border-bottom-right-radius: 7px; + border-top: 1px solid #575757; +} + +#recordings tfoot tr, #recordings tfoot td { + background: none; +} + + +#spinner { + text-align: center; + margin-top: 25%; + position: relative; +} + +#spinner span { + display: block; + position: relative; + top: -101px; + color: #e2334d; + font-size: 60px; + font-weight: bold; +} + + +#recordings tfoot tr { + height: 24px; +} + + +#instructions td { + white-space: normal; +} + +#instructions h1 { + font-size: 16px; + margin-top: 28px; +} + +#instructions h1 + p { + margin-top: 14px; +} + +#instructions p, #instructions ul { + margin-top: 21px; + font-size: 14px; +} + +#instructions p { + font-family: Raleway-Bold; +} + +#instructions ul { + font-family: Raleway-SemiBold; + margin-left: 21px; + font-weight: normal; +} + +#instructions li { + margin-top: 7px; +} + +#instructions ul input { + margin-left: 1px; + margin-top: 6px; + font-size: 14px; + padding: 0 7px; +} + + +#show-info-button { + font-family: HiFi-Glyphs; + font-size: 32px; + height: 16px; + line-height: 16px; + display: inline-block; + position: absolute; + top: 15px; + right: 5px; + margin-top: -11px; + margin-left: 7px; +} + +#show-info-button:hover { + color: #00b4ef; +} + + +#record-controls { + position: absolute; + bottom: 7px; + width: 100%; +} + +#record-controls #load-container { + position: absolute; + left: 21px; +} + +#record-controls #record-container { + text-align: center; +} + +#record-controls #checkbox-container { + margin-top: 31px; +} + +#record-controls div.property { + padding-left: 21px; +} + + +.hidden { + display: none; +} diff --git a/scripts/system/html/js/record.js b/scripts/system/html/js/record.js new file mode 100644 index 0000000000..c78500307d --- /dev/null +++ b/scripts/system/html/js/record.js @@ -0,0 +1,298 @@ +"use strict"; + +// +// record.js +// +// Created by David Rowe on 5 Apr 2017. +// Copyright 2017 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +var isUsingToolbar = false, + isDisplayingInstructions = false, + isRecording = false, + numberOfPlayers = 0, + recordingsBeingPlayed = [], + elRecordings, + elRecordingsTable, + elRecordingsList, + elInstructions, + elPlayersUnused, + elHideInfoButton, + elShowInfoButton, + elLoadButton, + elSpinner, + elCountdownNumber, + elRecordButton, + elFinishOnOpen, + elFinishOnOpenLabel, + EVENT_BRIDGE_TYPE = "record", + BODY_LOADED_ACTION = "bodyLoaded", + USING_TOOLBAR_ACTION = "usingToolbar", + RECORDINGS_BEING_PLAYED_ACTION = "recordingsBeingPlayed", + NUMBER_OF_PLAYERS_ACTION = "numberOfPlayers", + STOP_PLAYING_RECORDING_ACTION = "stopPlayingRecording", + LOAD_RECORDING_ACTION = "loadRecording", + START_RECORDING_ACTION = "startRecording", + SET_COUNTDOWN_NUMBER_ACTION = "setCountdownNumber", + STOP_RECORDING_ACTION = "stopRecording", + FINISH_ON_OPEN_ACTION = "finishOnOpen"; + +function stopPlayingRecording(event) { + var playerID = event.target.getAttribute("playerID"); + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: STOP_PLAYING_RECORDING_ACTION, + value: playerID + })); +} + +function updatePlayersUnused() { + elPlayersUnused.innerHTML = numberOfPlayers - recordingsBeingPlayed.length; +} + +function orderRecording(a, b) { + return a.filename > b.filename ? 1 : -1; +} + +function updateRecordings() { + var tbody, + tr, + td, + input, + ths, + tds, + length, + i, + HIFI_GLYPH_CLOSE = "w"; + + recordingsBeingPlayed.sort(orderRecording); + + tbody = document.createElement("tbody"); + tbody.id = "recordings-list"; + + + // Filename + for (i = 0, length = recordingsBeingPlayed.length; i < length; i += 1) { + tr = document.createElement("tr"); + td = document.createElement("td"); + td.innerHTML = recordingsBeingPlayed[i].filename.slice(4); + tr.appendChild(td); + td = document.createElement("td"); + input = document.createElement("input"); + input.setAttribute("type", "button"); + input.setAttribute("class", "glyph red"); + input.setAttribute("value", HIFI_GLYPH_CLOSE); + input.setAttribute("playerID", recordingsBeingPlayed[i].playerID); + input.addEventListener("click", stopPlayingRecording); + td.appendChild(input); + tr.appendChild(td); + tbody.appendChild(tr); + } + + // Empty rows representing available players. + for (i = recordingsBeingPlayed.length, length = numberOfPlayers; i < length; i += 1) { + tr = document.createElement("tr"); + td = document.createElement("td"); + td.colSpan = 2; + tr.appendChild(td); + tbody.appendChild(tr); + } + + // Filler row for extra table space. + tr = document.createElement("tr"); + tr.classList.add("filler"); + td = document.createElement("td"); + td.colSpan = 2; + tr.appendChild(td); + tbody.appendChild(tr); + + // Update table content. + elRecordingsTable.replaceChild(tbody, elRecordingsList); + elRecordingsList = document.getElementById("recordings-list"); + + // Update header cell widths to match content widths. + ths = document.querySelectorAll("#recordings-table thead th"); + tds = document.querySelectorAll("#recordings-table tbody tr:first-child td"); + for (i = 0; i < ths.length; i += 1) { + ths[i].width = tds[i].offsetWidth; + } +} + +function updateInstructions() { + // Display show/hide instructions buttons if players are available. + if (numberOfPlayers === 0) { + elHideInfoButton.classList.add("hidden"); + elShowInfoButton.classList.add("hidden"); + } else { + elHideInfoButton.classList.remove("hidden"); + elShowInfoButton.classList.remove("hidden"); + } + + // Display instructions if user requested or no players available. + if (isDisplayingInstructions || numberOfPlayers === 0) { + elRecordingsList.classList.add("hidden"); + elInstructions.classList.remove("hidden"); + } else { + elInstructions.classList.add("hidden"); + elRecordingsList.classList.remove("hidden"); + } +} + +function showInstructions() { + isDisplayingInstructions = true; + updateInstructions(); +} + +function hideInstructions() { + isDisplayingInstructions = false; + updateInstructions(); +} + +function updateLoadButton() { + if (isRecording || numberOfPlayers <= recordingsBeingPlayed.length) { + elLoadButton.setAttribute("disabled", "disabled"); + } else { + elLoadButton.removeAttribute("disabled"); + } +} + +function updateSpinner() { + if (isRecording) { + elRecordings.classList.add("hidden"); + elSpinner.classList.remove("hidden"); + } else { + elSpinner.classList.add("hidden"); + elRecordings.classList.remove("hidden"); + } +} + +function updateFinishOnOpenLabel() { + var WINDOW_FINISH_ON_OPEN_LABEL = "Stop recording automatically when reopen this window", + TABLET_FINISH_ON_OPEN_LABEL = "Stop recording automatically when reopen tablet or window"; + + elFinishOnOpenLabel.innerHTML = isUsingToolbar ? WINDOW_FINISH_ON_OPEN_LABEL : TABLET_FINISH_ON_OPEN_LABEL; +} + +function onScriptEventReceived(data) { + var message = JSON.parse(data); + if (message.type === EVENT_BRIDGE_TYPE) { + switch (message.action) { + case USING_TOOLBAR_ACTION: + isUsingToolbar = message.value; + updateFinishOnOpenLabel(); + break; + case FINISH_ON_OPEN_ACTION: + elFinishOnOpen.checked = message.value; + break; + case START_RECORDING_ACTION: + isRecording = true; + elRecordButton.value = "Stop"; + updateSpinner(); + updateLoadButton(); + break; + case SET_COUNTDOWN_NUMBER_ACTION: + elCountdownNumber.innerHTML = message.value; + break; + case STOP_RECORDING_ACTION: + isRecording = false; + elRecordButton.value = "Record"; + updateSpinner(); + updateLoadButton(); + break; + case RECORDINGS_BEING_PLAYED_ACTION: + recordingsBeingPlayed = JSON.parse(message.value); + updateRecordings(); + updatePlayersUnused(); + updateInstructions(); + updateLoadButton(); + break; + case NUMBER_OF_PLAYERS_ACTION: + numberOfPlayers = message.value; + updateRecordings(); + updatePlayersUnused(); + updateInstructions(); + updateLoadButton(); + break; + } + } +} + +function onLoadButtonClicked() { + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: LOAD_RECORDING_ACTION + })); +} + +function onRecordButtonClicked() { + if (!isRecording) { + elRecordButton.value = "Stop"; + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: START_RECORDING_ACTION + })); + isRecording = true; + updateSpinner(); + updateLoadButton(); + } else { + elRecordButton.value = "Record"; + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: STOP_RECORDING_ACTION + })); + isRecording = false; + updateSpinner(); + updateLoadButton(); + } +} + +function onFinishOnOpenClicked() { + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: FINISH_ON_OPEN_ACTION, + value: elFinishOnOpen.checked + })); +} + +function signalBodyLoaded() { + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: BODY_LOADED_ACTION + })); +} + +function onBodyLoaded() { + + EventBridge.scriptEventReceived.connect(onScriptEventReceived); + + elRecordings = document.getElementById("recordings"); + + elRecordingsTable = document.getElementById("recordings-table"); + elRecordingsList = document.getElementById("recordings-list"); + elInstructions = document.getElementById("instructions"); + elPlayersUnused = document.getElementById("players-unused"); + + elHideInfoButton = document.getElementById("hide-info-button"); + elHideInfoButton.onclick = hideInstructions; + elShowInfoButton = document.getElementById("show-info-button"); + elShowInfoButton.onclick = showInstructions; + + elLoadButton = document.getElementById("load-button"); + elLoadButton.onclick = onLoadButtonClicked; + + elSpinner = document.getElementById("spinner"); + elCountdownNumber = document.getElementById("countdown-number"); + + elRecordButton = document.getElementById("record-button"); + elRecordButton.onclick = onRecordButtonClicked; + + elFinishOnOpen = document.getElementById("finish-on-open"); + elFinishOnOpen.onclick = onFinishOnOpenClicked; + + elFinishOnOpenLabel = document.getElementById("finish-on-open-label"); + + signalBodyLoaded(); +} diff --git a/scripts/system/html/record.html b/scripts/system/html/record.html new file mode 100644 index 0000000000..14a3708a6a --- /dev/null +++ b/scripts/system/html/record.html @@ -0,0 +1,87 @@ + + + + + Record + + + + + +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
Recordings Being PlayedUnload
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ + + diff --git a/scripts/system/playRecordingAC.js b/scripts/system/playRecordingAC.js new file mode 100644 index 0000000000..b4fae9a2e3 --- /dev/null +++ b/scripts/system/playRecordingAC.js @@ -0,0 +1,439 @@ +"use strict"; + +// +// playRecordingAC.js +// +// Created by David Rowe on 7 Apr 2017. +// Copyright 2017 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 () { + + var APP_NAME = "PLAYBACK", + HIFI_RECORDER_CHANNEL = "HiFi-Recorder-Channel", + HIFI_PLAYER_CHANNEL = "HiFi-Player-Channel", + PLAYER_COMMAND_PLAY = "play", + PLAYER_COMMAND_STOP = "stop", + heartbeatTimer = null, + HEARTBEAT_INTERVAL = 3000, + TIMESTAMP_UPDATE_INTERVAL = 2500, + AUTOPLAY_SEARCH_INTERVAL = 5000, + AUTOPLAY_ERROR_INTERVAL = 30000, // 30s + scriptUUID, + + Entity, + Player; + + function log(message) { + print(APP_NAME + " " + scriptUUID + ": " + message); + } + + Entity = (function () { + // Persistence of playback via invisible entity. + var entityID = null, + userData, + updateTimestampTimer = null, + ENTITY_NAME = "Recording", + ENTITY_DESCRIPTION = "Avatar recording to play back", + ENTITIY_POSITION = { x: -16382, y: -16382, z: -16382 }, // Near but not right on domain corner. + ENTITY_SEARCH_DELTA = { x: 1, y: 1, z: 1 }, // Allow for position imprecision. + SEARCH_IDLE = 0, + SEARCH_SEARCHING = 1, + SEARCH_CLAIMING = 2, + SEARCH_PAUSING = 3, + searchState = SEARCH_IDLE, + otherPlayersPlaying, + otherPlayersPlayingCounts, + pauseCount; + + function onUpdateTimestamp() { + userData.timestamp = Date.now(); + Entities.editEntity(entityID, { userData: JSON.stringify(userData) }); + EntityViewer.queryOctree(); // Keep up to date ready for find(). + } + + function id() { + return entityID; + } + + function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + + function onMessageReceived(channel, message, sender) { + var index; + + if (sender !== scriptUUID) { + message = JSON.parse(message); + index = otherPlayersPlaying.indexOf(message.entity); + if (index !== -1) { + otherPlayersPlayingCounts[index] += 1; + } else { + otherPlayersPlaying.push(message.entity); + otherPlayersPlayingCounts.push(1); + } + } + } + + function create(filename, position, orientation) { + // Create a new persistence entity (even if already have one but that should never occur). + var properties; + + log("Create recording " + filename); + + if (updateTimestampTimer !== null) { + Script.clearInterval(updateTimestampTimer); // Just in case. + } + + searchState = SEARCH_IDLE; + + userData = { + recording: filename, + position: position, + orientation: orientation, + scriptUUID: scriptUUID, + timestamp: Date.now() + }; + + properties = { + type: "Box", + name: ENTITY_NAME, + description: ENTITY_DESCRIPTION, + position: ENTITIY_POSITION, + visible: false, + userData: JSON.stringify(userData) + }; + + entityID = Entities.addEntity(properties); + if (!Uuid.isNull(entityID)) { + updateTimestampTimer = Script.setInterval(onUpdateTimestamp, TIMESTAMP_UPDATE_INTERVAL); + return true; + } + + return false; + } + + function find() { + // Find a persistence entity that isn't being played. + // AC scripts may simultaneously find the same entity to play because octree updates aren't instantaneously + // propagated. Additionally, messages are not instantaneous. To address these issues the "find" progresses through + // the following search states: + // - SEARCH_IDLE + // No searching is being performed. + // Return null. + // - SEARCH_SEARCHING + // Looking for an entity that isn't being played (as reported in entity properties) and isn't being claimed (as + // reported by heartbeat messages. If one is found transition to SEARCH_CLAIMING and start reporting the entity + // in heartbeat messages. + // Return null. + // - SEARCH_CLAIMING + // An entity has been found and is reported in heartbeat messages but isn't being played yet. After a period of + // time, if no other players report they're playing that entity then transition to SEARCH_IDLE otherwise + // transition to SEARCH_PAUSING. + // If transitioning to SEARCH_IDLE update the entity userData and return the recording details, otherwise + // return null; + // - SEARCH_PAUSING + // Two or more players have tried to play the same entity. Wait for a randomized period of time before + // transitioning to SEARCH_SEARCHING. + // Return null. + // One of these states is processed each find() call. + var entityIDs, + index, + found = false, + properties, + numberOfClaims, + result = null; + + switch (searchState) { + + case SEARCH_IDLE: + log("Start searching"); + otherPlayersPlaying = []; + otherPlayersPlayingCounts = []; + Messages.subscribe(HIFI_RECORDER_CHANNEL); + Messages.messageReceived.connect(onMessageReceived); + searchState = SEARCH_SEARCHING; + break; + + case SEARCH_SEARCHING: + // Find an entity that isn't being played or claimed. + entityIDs = Entities.findEntities(ENTITIY_POSITION, ENTITY_SEARCH_DELTA.x); + if (entityIDs.length > 0) { + index = -1; + while (!found && index < entityIDs.length - 1) { + index += 1; + if (otherPlayersPlaying.indexOf(entityIDs[index]) === -1) { + properties = Entities.getEntityProperties(entityIDs[index], ["name", "userData"]); + userData = JSON.parse(properties.userData); + found = properties.name === ENTITY_NAME && userData.recording !== undefined; + } + } + } + + // Claim entity if found. + if (found) { + log("Claim entity " + entityIDs[index]); + entityID = entityIDs[index]; + searchState = SEARCH_CLAIMING; + } + break; + + case SEARCH_CLAIMING: + // How many other players are claiming (or playing) this entity? + index = otherPlayersPlaying.indexOf(entityID); + numberOfClaims = index !== -1 ? otherPlayersPlayingCounts[index] : 0; + + // Have found an entity to play if no other players are also claiming it. + if (numberOfClaims === 0) { + log("Complete claim " + entityID); + Messages.messageReceived.disconnect(onMessageReceived); + Messages.unsubscribe(HIFI_RECORDER_CHANNEL); + searchState = SEARCH_IDLE; + userData.scriptUUID = scriptUUID; + userData.timestamp = Date.now(); + Entities.editEntity(entityID, { userData: JSON.stringify(userData) }); + updateTimestampTimer = Script.setInterval(onUpdateTimestamp, TIMESTAMP_UPDATE_INTERVAL); + result = { recording: userData.recording, position: userData.position, orientation: userData.orientation }; + break; + } + + // Otherwise back off for a bit before resuming search. + log("Release claim " + entityID + " and pause searching"); + entityID = null; + pauseCount = randomInt(0, otherPlayersPlaying.length); + searchState = SEARCH_PAUSING; + break; + + case SEARCH_PAUSING: + // Resume searching if have paused long enough. + pauseCount -= 1; + if (pauseCount < 0) { + log("Resume searching"); + otherPlayersPlaying = []; + otherPlayersPlayingCounts = []; + searchState = SEARCH_SEARCHING; + } + break; + } + + EntityViewer.queryOctree(); + return result; + } + + function destroy() { + // Delete current persistence entity. + if (entityID !== null) { // Just in case. + Entities.deleteEntity(entityID); + entityID = null; + searchState = SEARCH_IDLE; + } + if (updateTimestampTimer !== null) { // Just in case. + Script.clearInterval(updateTimestampTimer); + } + } + + function setUp() { + // Set up EntityViewer so that can do Entities.findEntities(). + // Position and orientation set so that viewing entities only in corner of domain. + var entityViewerPosition = Vec3.sum(ENTITIY_POSITION, ENTITY_SEARCH_DELTA); + EntityViewer.setPosition(entityViewerPosition); + EntityViewer.setOrientation(Quat.lookAtSimple(entityViewerPosition, ENTITIY_POSITION)); + EntityViewer.queryOctree(); + } + + function tearDown() { + // Nothing to do. + } + + return { + id: id, + create: create, + find: find, + destroy: destroy, + setUp: setUp, + tearDown: tearDown + }; + }()); + + Player = (function () { + // Recording playback functions. + var isPlayingRecording = false, + recordingFilename = "", + autoPlayTimer = null, + + playRecording; + + function play(recording, position, orientation) { + if (Entity.create(recording, position, orientation)) { + log("Play new recording " + recordingFilename); + isPlayingRecording = true; + recordingFilename = recording; + playRecording(recordingFilename, position, orientation); + } else { + log("Could not create entity to play new recording " + recordingFilename); + } + } + + function autoPlay() { + var recording, + AUTOPLAY_SEARCH_DELTA = 1000; + + // Random delay to help reduce collisions between AC scripts. + Script.setTimeout(function () { + recording = Entity.find(); + if (recording) { + log("Play persisted recording " + recordingFilename); + playRecording(recording.recording, recording.position, recording.orientation); + } else { + autoPlayTimer = Script.setTimeout(autoPlay, AUTOPLAY_SEARCH_INTERVAL); // Try again soon. + } + }, Math.random() * AUTOPLAY_SEARCH_DELTA); + } + + playRecording = function (recording, position, orientation) { + Recording.loadRecording(recording, function (success) { + if (success) { + Users.disableIgnoreRadius(); + + Agent.isAvatar = true; + Avatar.position = position; + Avatar.orientation = orientation; + + Recording.setPlayFromCurrentLocation(true); + Recording.setPlayerUseDisplayName(true); + Recording.setPlayerUseHeadModel(false); + Recording.setPlayerUseAttachments(true); + Recording.setPlayerLoop(true); + Recording.setPlayerUseSkeletonModel(true); + + isPlayingRecording = true; + recordingFilename = recording; + + Recording.setPlayerTime(0.0); + Recording.startPlaying(); + + UserActivityLogger.logAction("playRecordingAC_play_recording"); + } else { + log("Failed to load recording " + recording); + autoPlayTimer = Script.setTimeout(autoPlay, AUTOPLAY_ERROR_INTERVAL); // Try again later. + } + }); + }; + + function stop() { + log("Stop playing " + recordingFilename); + + Entity.destroy(); + + if (Recording.isPlaying()) { + Recording.stopPlaying(); + Agent.isAvatar = false; + } + isPlayingRecording = false; + recordingFilename = ""; + } + + function isPlaying() { + return isPlayingRecording; + } + + function recording() { + return recordingFilename; + } + + function setUp() { + Entity.setUp(); + } + + function tearDown() { + if (autoPlayTimer) { + Script.clearTimeout(autoPlayTimer); + autoPlayTimer = null; + } + Entity.tearDown(); + } + + return { + autoPlay: autoPlay, + play: play, + stop: stop, + isPlaying: isPlaying, + recording: recording, + setUp: setUp, + tearDown: tearDown + }; + }()); + + function sendHeartbeat() { + Messages.sendMessage(HIFI_RECORDER_CHANNEL, JSON.stringify({ + playing: Player.isPlaying(), + recording: Player.recording(), + entity: Entity.id() + })); + heartbeatTimer = Script.setTimeout(sendHeartbeat, HEARTBEAT_INTERVAL); + } + + function stopHeartbeat() { + if (heartbeatTimer) { + Script.clearTimeout(heartbeatTimer); + heartbeatTimer = null; + } + } + + function onMessageReceived(channel, message, sender) { + if (channel !== HIFI_PLAYER_CHANNEL) { + return; + } + + message = JSON.parse(message); + if (message.player === scriptUUID) { + switch (message.command) { + case PLAYER_COMMAND_PLAY: + if (!Player.isPlaying()) { + Player.play(message.recording, message.position, message.orientation); + } else { + log("Didn't start playing " + message.recording + " because already playing " + Player.recording()); + } + sendHeartbeat(); + break; + case PLAYER_COMMAND_STOP: + Player.stop(); + Player.autoPlay(); // There may be another recording to play. + sendHeartbeat(); + break; + } + } + } + + function setUp() { + scriptUUID = Agent.sessionUUID; + + Player.setUp(); + + Messages.messageReceived.connect(onMessageReceived); + Messages.subscribe(HIFI_PLAYER_CHANNEL); + + Player.autoPlay(); + sendHeartbeat(); + + UserActivityLogger.logAction("playRecordingAC_script_load"); + } + + function tearDown() { + stopHeartbeat(); + Player.stop(); + + Messages.messageReceived.disconnect(onMessageReceived); + Messages.unsubscribe(HIFI_PLAYER_CHANNEL); + + Player.tearDown(); + } + + setUp(); + Script.scriptEnding.connect(tearDown); + +}()); diff --git a/scripts/system/record.js b/scripts/system/record.js new file mode 100644 index 0000000000..9b231f64f1 --- /dev/null +++ b/scripts/system/record.js @@ -0,0 +1,694 @@ +"use strict"; + +// +// record.js +// +// Created by David Rowe on 5 Apr 2017. +// Copyright 2017 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 () { + + var APP_NAME = "RECORD", + APP_ICON_INACTIVE = "icons/tablet-icons/avatar-record-i.svg", + APP_ICON_ACTIVE = "icons/tablet-icons/avatar-record-a.svg", + APP_URL = Script.resolvePath("html/record.html"), + isDialogDisplayed = false, + tablet, + button, + isConnected, + + RecordingIndicator, + Recorder, + Player, + Dialog, + + SCRIPT_STARTUP_DELAY = 3000; // 3s + + function log(message) { + print(APP_NAME + ": " + message); + } + + function error(message, info) { + print(APP_NAME + ": " + message + (info !== undefined ? " - " + info : "")); + Window.alert(message); + } + + RecordingIndicator = (function () { + // Displays "recording" overlay. + + var hmdOverlay, + HMD_FONT_SIZE = 0.08, + desktopOverlay, + DESKTOP_FONT_SIZE = 24; + + function show() { + // Create both overlays in case user switches desktop/HMD mode. + var screenSize = Controller.getViewportDimensions(), + recordingText = "REC", // Unicode circle \u25cf doesn't render in HMD. + CAMERA_JOINT_INDEX = -7; + + if (HMD.active) { + // 3D overlay attached to avatar. + hmdOverlay = Overlays.addOverlay("text3d", { + text: recordingText, + dimensions: { x: 3 * HMD_FONT_SIZE, y: HMD_FONT_SIZE }, + parentID: MyAvatar.sessionUUID, + parentJointIndex: CAMERA_JOINT_INDEX, + localPosition: { x: 0.95, y: 0.95, z: -2.0 }, + color: { red: 255, green: 0, blue: 0 }, + alpha: 0.9, + lineHeight: HMD_FONT_SIZE, + backgroundAlpha: 0, + ignoreRayIntersection: true, + isFacingAvatar: true, + drawInFront: true, + visible: true + }); + } else { + // 2D overlay on desktop. + desktopOverlay = Overlays.addOverlay("text", { + text: recordingText, + width: 3 * DESKTOP_FONT_SIZE, + height: DESKTOP_FONT_SIZE, + x: screenSize.x - 4 * DESKTOP_FONT_SIZE, + y: DESKTOP_FONT_SIZE, + font: { size: DESKTOP_FONT_SIZE }, + color: { red: 255, green: 8, blue: 8 }, + alpha: 1.0, + backgroundAlpha: 0, + visible: true + }); + } + } + + function hide() { + if (desktopOverlay) { + Overlays.deleteOverlay(desktopOverlay); + } + if (hmdOverlay) { + Overlays.deleteOverlay(hmdOverlay); + } + } + + return { + show: show, + hide: hide + }; + }()); + + Recorder = (function () { + // Makes the recording and uploads it to the domain's Asset Server. + var IDLE = 0, + COUNTING_DOWN = 1, + RECORDING = 2, + recordingState = IDLE, + mappingPath, + startPosition, + startOrientation, + play, + + countdownTimer, + countdownSeconds, + COUNTDOWN_SECONDS = 3, + + tickSound, + startRecordingSound, + finishRecordingSound, + TICK_SOUND = "assets/sounds/countdown-tick.wav", + START_RECORDING_SOUND = "assets/sounds/start-recording.wav", + FINISH_RECORDING_SOUND = "assets/sounds/finish-recording.wav", + START_RECORDING_SOUND_DURATION = 1200, + SOUND_VOLUME = 0.2; + + function playSound(sound) { + Audio.playSound(sound, { + position: MyAvatar.position, + localOnly: true, + volume: SOUND_VOLUME + }); + } + + function setMappingCallback(status) { + if (status !== "") { + error("Error mapping recording to " + mappingPath + " on Asset Server!", status); + return; + } + + log("Recording mapped to " + mappingPath); + log("Request play recording"); + + play("atp:" + mappingPath, startPosition, startOrientation); + } + + function saveRecordingToAssetCallback(url) { + var filename, + hash; + + if (url === "") { + error("Error saving recording to Asset Server!"); + return; + } + + log("Recording saved to Asset Server as " + url); + + filename = (new Date()).toISOString(); // yyyy-mm-ddThh:mm:ss.sssZ + filename = filename.replace(/[\-:]|\.\d*Z$/g, "").replace("T", "-") + ".hfr"; // yyyymmmdd-hhmmss.hfr + hash = url.slice(4); // Remove leading "atp:" from url. + mappingPath = "/recordings/" + filename; + Assets.setMapping(mappingPath, hash, setMappingCallback); + } + + function startRecording() { + recordingState = RECORDING; + log("Start recording"); + playSound(startRecordingSound); + Script.setTimeout(function () { + // Delay start so that start beep is not included in recorded sound. + startPosition = MyAvatar.position; + startOrientation = MyAvatar.orientation; + Recording.startRecording(); + RecordingIndicator.show(); + }, START_RECORDING_SOUND_DURATION); + } + + function finishRecording() { + var success, + error; + + recordingState = IDLE; + log("Finish recording"); + UserActivityLogger.logAction("record_finish_recording"); + playSound(finishRecordingSound); + Recording.stopRecording(); + RecordingIndicator.hide(); + success = Recording.saveRecordingToAsset(saveRecordingToAssetCallback); + if (!success) { + error("Error saving recording to Asset Server!"); + } + } + + function cancelRecording() { + Recording.stopRecording(); + RecordingIndicator.hide(); + recordingState = IDLE; + log("Cancel recording"); + } + + function finishCountdown() { + Dialog.setCountdownNumber(""); + recordingState = RECORDING; + startRecording(); + } + + function cancelCountdown() { + recordingState = IDLE; + Script.clearInterval(countdownTimer); + Dialog.setCountdownNumber(""); + log("Cancel countdown"); + } + + function startCountdown() { + recordingState = COUNTING_DOWN; + log("Start countdown"); + countdownSeconds = COUNTDOWN_SECONDS; + Dialog.setCountdownNumber(countdownSeconds); + playSound(tickSound); + countdownTimer = Script.setInterval(function () { + countdownSeconds -= 1; + if (countdownSeconds <= 0) { + Script.clearInterval(countdownTimer); + finishCountdown(); + } else { + Dialog.setCountdownNumber(countdownSeconds); + playSound(tickSound); + } + }, 1000); + } + + function isIdle() { + return recordingState === IDLE; + } + + function isCountingDown() { + return recordingState === COUNTING_DOWN; + } + + function isRecording() { + return recordingState === RECORDING; + } + + function setUp(playerCallback) { + play = playerCallback; + + tickSound = SoundCache.getSound(Script.resolvePath(TICK_SOUND)); + startRecordingSound = SoundCache.getSound(Script.resolvePath(START_RECORDING_SOUND)); + finishRecordingSound = SoundCache.getSound(Script.resolvePath(FINISH_RECORDING_SOUND)); + } + + function tearDown() { + // Nothing to do; any cancelling of recording needs to be done by script using this object. + } + + return { + startCountdown: startCountdown, + cancelCountdown: cancelCountdown, + startRecording: startRecording, + cancelRecording: cancelRecording, + finishRecording: finishRecording, + isIdle: isIdle, + isCountingDown: isCountingDown, + isRecording: isRecording, + setUp: setUp, + tearDown: tearDown + }; + }()); + + Player = (function () { + var HIFI_RECORDER_CHANNEL = "HiFi-Recorder-Channel", + HIFI_PLAYER_CHANNEL = "HiFi-Player-Channel", + PLAYER_COMMAND_PLAY = "play", + PLAYER_COMMAND_STOP = "stop", + + playerIDs = [], // UUIDs of AC player scripts. + playerIsPlayings = [], // True if AC player script is playing a recording. + playerRecordings = [], // Assignment client mappings of recordings being played. + playerTimestamps = [], // Timestamps of last heartbeat update from player script. + + updateTimer, + UPDATE_INTERVAL = 5000; // Must be > player's HEARTBEAT_INTERVAL. + + function numberOfPlayers() { + return playerIDs.length; + } + + function updatePlayers() { + var now = Date.now(), + countBefore = playerIDs.length, + i; + + // Remove players that haven't sent a heartbeat for a while. + for (i = playerTimestamps.length - 1; i >= 0; i -= 1) { + if (now - playerTimestamps[i] > UPDATE_INTERVAL) { + playerIDs.splice(i, 1); + playerIsPlayings.splice(i, 1); + playerRecordings.splice(i, 1); + playerTimestamps.splice(i, 1); + } + } + + // Update UI. + if (playerIDs.length !== countBefore) { + Dialog.updatePlayerDetails(playerIsPlayings, playerRecordings, playerIDs); + } + } + + function playRecording(recording, position, orientation) { + var index, + CHECK_PLAYING_TIMEOUT = 10000; + + // Optional function parameters. + if (position === undefined) { + position = MyAvatar.position; + } + if (orientation === undefined) { + orientation = MyAvatar.orientation; + } + + index = playerIsPlayings.indexOf(false); + if (index === -1) { + error("No player instance available to play recording " + + recording.slice(4) + "!"); // Remove leading "atp:" from recording. + return; + } + + Messages.sendMessage(HIFI_PLAYER_CHANNEL, JSON.stringify({ + player: playerIDs[index], + command: PLAYER_COMMAND_PLAY, + recording: recording, + position: position, + orientation: orientation + })); + + Script.setTimeout(function () { + if (!playerIsPlayings[index] || playerRecordings[index] !== recording) { + error("Didn't start playing recording " + + recording.slice(4) + "!"); // Remove leading "atp:" from recording. + } + }, CHECK_PLAYING_TIMEOUT); + + } + + function stopPlayingRecording(playerID) { + Messages.sendMessage(HIFI_PLAYER_CHANNEL, JSON.stringify({ + player: playerID, + command: PLAYER_COMMAND_STOP + })); + } + + function onMessageReceived(channel, message, sender) { + // Heartbeat from AC script. + var index; + + if (channel !== HIFI_RECORDER_CHANNEL) { + return; + } + + message = JSON.parse(message); + + index = playerIDs.indexOf(sender); + if (index === -1) { + index = playerIDs.length; + playerIDs[index] = sender; + } + playerIsPlayings[index] = message.playing; + playerRecordings[index] = message.recording; + playerTimestamps[index] = Date.now(); + Dialog.updatePlayerDetails(playerIsPlayings, playerRecordings, playerIDs); + } + + function reset() { + playerIDs = []; + playerIsPlayings = []; + playerRecordings = []; + playerTimestamps = []; + Dialog.updatePlayerDetails(playerIsPlayings, playerRecordings, playerIDs); + } + + function setUp() { + // Messaging with AC scripts. + Messages.messageReceived.connect(onMessageReceived); + Messages.subscribe(HIFI_RECORDER_CHANNEL); + + updateTimer = Script.setInterval(updatePlayers, UPDATE_INTERVAL); + } + + function tearDown() { + Script.clearInterval(updateTimer); + + Messages.messageReceived.disconnect(onMessageReceived); + Messages.subscribe(HIFI_RECORDER_CHANNEL); + } + + return { + playRecording: playRecording, + stopPlayingRecording: stopPlayingRecording, + numberOfPlayers: numberOfPlayers, + reset: reset, + setUp: setUp, + tearDown: tearDown + }; + }()); + + Dialog = (function () { + var isFinishOnOpen = false, + countdownNumber = "", + EVENT_BRIDGE_TYPE = "record", + BODY_LOADED_ACTION = "bodyLoaded", + USING_TOOLBAR_ACTION = "usingToolbar", + RECORDINGS_BEING_PLAYED_ACTION = "recordingsBeingPlayed", + NUMBER_OF_PLAYERS_ACTION = "numberOfPlayers", + STOP_PLAYING_RECORDING_ACTION = "stopPlayingRecording", + LOAD_RECORDING_ACTION = "loadRecording", + START_RECORDING_ACTION = "startRecording", + SET_COUNTDOWN_NUMBER_ACTION = "setCountdownNumber", + STOP_RECORDING_ACTION = "stopRecording", + FINISH_ON_OPEN_ACTION = "finishOnOpen", + SETTINGS_FINISH_ON_OPEN = "record/finishOnOpen"; + + function isUsingToolbar() { + return ((HMD.active && Settings.getValue("hmdTabletBecomesToolbar")) + || (!HMD.active && Settings.getValue("desktopTabletBecomesToolbar"))); + } + + function updateRecordingStatus(isRecording) { + if (isRecording) { + tablet.emitScriptEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: START_RECORDING_ACTION + })); + tablet.emitScriptEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: SET_COUNTDOWN_NUMBER_ACTION, + value: countdownNumber + })); + } else { + tablet.emitScriptEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: STOP_RECORDING_ACTION + })); + } + } + + function updatePlayerDetails(playerIsPlayings, playerRecordings, playerIDs) { + var recordingsBeingPlayed = [], + length, + i; + + for (i = 0, length = playerIsPlayings.length; i < length; i += 1) { + if (playerIsPlayings[i]) { + recordingsBeingPlayed.push({ + filename: playerRecordings[i], + playerID: playerIDs[i] + }); + } + } + tablet.emitScriptEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: RECORDINGS_BEING_PLAYED_ACTION, + value: JSON.stringify(recordingsBeingPlayed) + })); + + tablet.emitScriptEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: NUMBER_OF_PLAYERS_ACTION, + value: playerIsPlayings.length + })); + } + + function setCountdownNumber(number) { + countdownNumber = number; + tablet.emitScriptEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: SET_COUNTDOWN_NUMBER_ACTION, + value: countdownNumber + })); + } + + function finishOnOpen() { + return isFinishOnOpen; + } + + function onWebEventReceived(data) { + var message, + recording; + + message = JSON.parse(data); + if (message.type === EVENT_BRIDGE_TYPE) { + switch (message.action) { + case BODY_LOADED_ACTION: + // Dialog's ready; initialize its state. + tablet.emitScriptEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: USING_TOOLBAR_ACTION, + value: isUsingToolbar() + })); + tablet.emitScriptEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: FINISH_ON_OPEN_ACTION, + value: isFinishOnOpen + })); + tablet.emitScriptEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: NUMBER_OF_PLAYERS_ACTION, + value: Player.numberOfPlayers() + })); + updateRecordingStatus(!Recorder.isIdle()); + UserActivityLogger.logAction("record_open_dialog"); + break; + case STOP_PLAYING_RECORDING_ACTION: + // Stop the specified player. + Player.stopPlayingRecording(message.value); + break; + case LOAD_RECORDING_ACTION: + // User wants to select an ATP recording to play. + recording = Window.browseAssets("Select Recording to Play", "recordings", "*.hfr"); + if (recording) { + log("Load recording " + recording); + UserActivityLogger.logAction("record_load_recording"); + Player.playRecording("atp:" + recording, MyAvatar.position, MyAvatar.orientation); + } + break; + case START_RECORDING_ACTION: + // Start making a recording. + if (Recorder.isIdle()) { + Recorder.startCountdown(); + } + break; + case STOP_RECORDING_ACTION: + // Cancel or finish a recording. + if (Recorder.isCountingDown()) { + Recorder.cancelCountdown(); + } else if (Recorder.isRecording()) { + Recorder.finishRecording(); + } + break; + case FINISH_ON_OPEN_ACTION: + // Set behavior on dialog open. + isFinishOnOpen = message.value; + Settings.setValue(SETTINGS_FINISH_ON_OPEN, isFinishOnOpen); + break; + } + } + } + + function setUp() { + isFinishOnOpen = Settings.getValue(SETTINGS_FINISH_ON_OPEN) === true; + tablet.webEventReceived.connect(onWebEventReceived); + } + + function tearDown() { + tablet.webEventReceived.disconnect(onWebEventReceived); + } + + return { + updatePlayerDetails: updatePlayerDetails, + updateRecordingStatus: updateRecordingStatus, + setCountdownNumber: setCountdownNumber, + finishOnOpen: finishOnOpen, + setUp: setUp, + tearDown: tearDown + }; + }()); + + function onTabletScreenChanged(type, url) { + // Opened/closed dialog in tablet or window. + var RECORD_URL = "/scripts/system/html/record.html"; + + if (type === "Web" && url.slice(-RECORD_URL.length) === RECORD_URL) { + if (Dialog.finishOnOpen()) { + // Cancel countdown or finish recording. + if (Recorder.isCountingDown()) { + Recorder.cancelCountdown(); + } else if (Recorder.isRecording()) { + Recorder.finishRecording(); + } + Dialog.updateRecordingStatus(false); + } + isDialogDisplayed = true; + } else { + isDialogDisplayed = false; + } + button.editProperties({ isActive: isDialogDisplayed }); + } + + function onTabletShownChanged() { + // Opened/closed tablet. + if (tablet.tabletShown && Dialog.finishOnOpen()) { + // Cancel countdown or finish recording. + if (Recorder.isCountingDown()) { + Recorder.cancelCountdown(); + } else if (Recorder.isRecording()) { + Recorder.finishRecording(); + } + Dialog.updateRecordingStatus(false); + } + } + + function onButtonClicked() { + if (isDialogDisplayed) { + // Can click icon in toolbar mode; gotoHomeScreen() closes dialog. + tablet.gotoHomeScreen(); + isDialogDisplayed = false; + } else { + tablet.gotoWebScreen(APP_URL); + isDialogDisplayed = true; + } + } + + function onUpdate() { + if (isConnected !== Window.location.isConnected) { + // Server restarted or domain changed. + isConnected = !isConnected; + if (!isConnected) { + // Clear dialog. + Player.reset(); + } + } + } + + function setUp() { + tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); + if (!tablet) { + return; + } + + // Tablet/toolbar button. + button = tablet.addButton({ + icon: APP_ICON_INACTIVE, + activeIcon: APP_ICON_ACTIVE, + text: APP_NAME, + isActive: false + }); + if (button) { + button.clicked.connect(onButtonClicked); + } + + // Track showing/hiding tablet/dialog. + tablet.screenChanged.connect(onTabletScreenChanged); + tablet.tabletShownChanged.connect(onTabletShownChanged); + + Dialog.setUp(); + Player.setUp(); + Recorder.setUp(Player.playRecording); + + isConnected = Window.location.isConnected; + Script.update.connect(onUpdate); + + UserActivityLogger.logAction("record_run_script"); + } + + function tearDown() { + if (!tablet) { + return; + } + + Script.update.disconnect(onUpdate); + + Recorder.tearDown(); + Player.tearDown(); + Dialog.tearDown(); + + tablet.tabletShownChanged.disconnect(onTabletShownChanged); + tablet.screenChanged.disconnect(onTabletScreenChanged); + if (button) { + button.clicked.disconnect(onButtonClicked); + tablet.removeButton(button); + button = null; + } + + if (Recorder.isCountingDown()) { + Recorder.cancelCountdown(); + } else if (Recorder.isRecording()) { + Recorder.cancelRecording(); + } + + if (isDialogDisplayed) { + tablet.gotoHomeScreen(); + } + + tablet = null; + } + + // FIXME: If setUp() is run immediately at Interface start-up, Interface hangs and crashes because of the line of code: + // tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); + //setUp(); + //Script.scriptEnding.connect(tearDown); + Script.setTimeout(function () { + setUp(); + Script.scriptEnding.connect(tearDown); + }, SCRIPT_STARTUP_DELAY); +}());