diff --git a/.github/workflows/build-checks.yml b/.github/workflows/build-checks.yml index c40f5653de04..63453b11df75 100644 --- a/.github/workflows/build-checks.yml +++ b/.github/workflows/build-checks.yml @@ -94,6 +94,7 @@ jobs: -DCMAKE_BUILD_TYPE=Debug \ -DOPTIMIZE=off \ -DQT6=ON \ + -DQML=ON \ -DCOVERAGE=ON \ -DWARNINGS_FATAL=OFF \ -DDEBUG_ASSERTIONS_FATAL=OFF \ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e09a15cdec8b..dbbc428761e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,6 +47,7 @@ jobs: -DMACOS_BUNDLE=ON -DMODPLUG=ON -DQT6=ON + -DQML=OFF -DWAVPACK=ON -DVCPKG_TARGET_TRIPLET=x64-osx-min1100-release -DVCPKG_DEFAULT_HOST_TRIPLET=x64-osx-min1100-release @@ -71,6 +72,7 @@ jobs: -DMACOS_BUNDLE=ON -DMODPLUG=ON -DQT6=ON + -DQML=OFF -DWAVPACK=ON -DVCPKG_TARGET_TRIPLET=arm64-osx-min1100-release -DVCPKG_DEFAULT_HOST_TRIPLET=x64-osx-min1100-release @@ -101,6 +103,7 @@ jobs: -DMEDIAFOUNDATION=ON -DMODPLUG=ON -DQT6=ON + -DQML=OFF -DWAVPACK=ON -DVCPKG_TARGET_TRIPLET=x64-windows-release -DVCPKG_DEFAULT_HOST_TRIPLET=x64-windows-release diff --git a/CMakeLists.txt b/CMakeLists.txt index 52e1265a36be..86dced62515b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1494,6 +1494,13 @@ if (NOT QML) src/control/controlmodel.cpp src/control/controlsortfiltermodel.cpp ) +else() + target_sources(mixxx-lib PRIVATE + # The following source depends of QML being available but aren't part of the new QML UI + src/controllers/rendering/controllerrenderingengine.cpp + src/controllers/controllerenginethreadcontrol.cpp + src/controllers/controllerscreenpreview.cpp + ) endif() if(QOPENGL) target_sources(mixxx-lib PRIVATE @@ -2176,7 +2183,14 @@ add_executable(mixxx-test src/test/wwidgetstack_test.cpp src/test/waveform_upgrade_test.cpp src/util/moc_included_test.cpp + src/test/helpers/log_test.cpp ) +if (QML) + target_sources(mixxx-test PRIVATE + src/test/controller_mapping_file_handler_test.cpp + src/test/controllerrenderingengine_test.cpp + ) +endif() find_package(GTest CONFIG REQUIRED) set_target_properties(mixxx-test PROPERTIES AUTOMOC ON) target_link_libraries(mixxx-test PRIVATE mixxx-lib mixxx-gitinfostore GTest::gtest GTest::gmock) diff --git a/res/controllers/Dummy Device Screen.hid.xml b/res/controllers/Dummy Device Screen.hid.xml new file mode 100644 index 000000000000..90526cc1a33f --- /dev/null +++ b/res/controllers/Dummy Device Screen.hid.xml @@ -0,0 +1,22 @@ + + + + Dummy Device (Screens) + A. Colombier + Dummy device screens + + + + + + + + + + + + + + + + diff --git a/res/controllers/DummyDeviceDefaultScreen.qml b/res/controllers/DummyDeviceDefaultScreen.qml new file mode 100755 index 000000000000..c216f2259d6f --- /dev/null +++ b/res/controllers/DummyDeviceDefaultScreen.qml @@ -0,0 +1,305 @@ +import QtQuick 2.15 +import QtQuick.Window 2.3 + +import QtQuick.Controls 2.15 +import QtQuick.Shapes 1.11 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.15 + +import Qt5Compat.GraphicalEffects + +import Mixxx 1.0 as Mixxx +import Mixxx.Controls 1.0 as MixxxControls + +import "." as Skin + +Item { + id: root + + required property string screenId + property color fontColor: Qt.rgba(242/255,242/255,242/255, 1) + property color smallBoxBorder: Qt.rgba(44/255,44/255,44/255, 1) + + property string group: "[Channel1]" + property var deckPlayer: Mixxx.PlayerManager.getPlayer(root.group) + + function init(controlerName, isDebug) { + console.log(`Screen ${root.screenId} has started`) + switch (root.screenId) { + case "jog": + loader.sourceComponent = jog + break; + default: + loader.sourceComponent = main + } + } + + function shutdown() { + console.log(`Screen ${root.screenId} is stopping`) + loader.sourceComponent = splash + } + + // function transformFrame(input: ArrayBuffer, timestamp: date) { + function transformFrame(input, timestamp) { + return new ArrayBuffer(0); + } + + Mixxx.ControlProxy { + group: root.group + key: "track_loaded" + + onValueChanged: (value) => { + deckPlayer = Mixxx.PlayerManager.getPlayer(root.group) + } + } + + Timer { + id: channelchange + + interval: 2000 + repeat: true + running: true + + onTriggered: { + root.group = root.group === "[Channel1]" ? "[Channel2]" : "[Channel1]" + deckPlayer = Mixxx.PlayerManager.getPlayer(root.group) + } + } + + Component { + id: splash + Rectangle { + color: "black" + anchors.fill: parent + Image { + anchors.fill: parent + fillMode: Image.PreserveAspectFit + source: "../images/templates/logo_mixxx.png" + } + } + } + + Component { + id: jog + + Rectangle { + anchors.fill: parent + color: "black" + + Image { + id: artwork + anchors.fill: parent + visible: deckPlayer.trackLocationUrl.toString().length !== 0 + + source: deckPlayer.coverArtUrl ?? "../images/templates/logo_mixxx.png" + height: 100 + width: 100 + fillMode: Image.PreserveAspectFit + } + + Text { + visible: deckPlayer.trackLocationUrl.toString().length === 0 + + text: qsTr("No Track Loaded") + font.pixelSize: 12 + font.family: "Noto Sans" + font.letterSpacing: -1 + color: "white" + } + } + } + + Component { + id: main + + Rectangle { + id: debugValue + anchors.fill: parent + color: 'black' + + antialiasing: true + + ColumnLayout { + id: column + anchors.fill: parent + anchors.leftMargin: 0 + anchors.rightMargin: 0 + anchors.topMargin: 0 + anchors.bottomMargin: 0 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + spacing: 0 + + Repeater { + id: debugColor + + model: [ + "black", + "white", + "red", + "green", + "blue", + Qt.rgba(0, 1, 1), + ] + + Rectangle { + required property var modelData + + color: modelData + Layout.fillWidth: true + height: 80 + } + } + } + + RowLayout { + anchors.leftMargin: 6 + anchors.rightMargin: 6 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 6 + + Rectangle { + color: 'transparent' + Layout.fillWidth: true + Layout.fillHeight: true + Text { + text: qsTr("Group") + font.pixelSize: 24 + font.family: "Noto Sans" + font.letterSpacing: -1 + color: fontColor + } + } + + Rectangle { + color: 'transparent' + Layout.fillWidth: true + Layout.fillHeight: true + Text { + text: `${root.group}` + font.pixelSize: 24 + font.family: "Noto Sans" + font.letterSpacing: -1 + color: fontColor + } + } + } + + RowLayout { + anchors.leftMargin: 6 + anchors.rightMargin: 6 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 6 + + Rectangle { + color: 'transparent' + Layout.fillWidth: true + Layout.fillHeight: true + Text { + text: qsTr("Widget") + font.pixelSize: 24 + font.family: "Noto Sans" + font.letterSpacing: -1 + color: fontColor + } + } + + Rectangle { + color: 'transparent' + Layout.fillWidth: true + Layout.fillHeight: true + + Skin.HotcueButton { + anchors.fill: parent + + hotcueNumber: 1 + group: root.group + } + } + } + + Repeater { + model: [{ + controllerKey: "beatloop_size", + title: "Beatloop Size" + }, { + controllerKey: "track_samples", + title: "Track sample" + }, { + controllerKey: "track_samplerate", + title: "Track sample rate" + }, { + controllerKey: "playposition", + title: "Play position" + }, { + controllerKey: "rate_ratio", + title: "Rate ratio" + }, { + controllerKey: "waveform_zoom", + title: "Waveform zoom" + } + ] + + RowLayout { + id: row + anchors.leftMargin: 6 + anchors.rightMargin: 6 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 6 + required property var modelData + + Mixxx.ControlProxy { + id: mixxxValue + group: root.group + key: modelData.controllerKey + } + + Rectangle { + color: 'transparent' + Layout.fillWidth: true + Layout.fillHeight: true + Text { + text: qsTr(modelData.title) + font.pixelSize: 24 + font.family: "Noto Sans" + font.letterSpacing: -1 + color: fontColor + } + } + + Rectangle { + color: 'transparent' + Layout.fillWidth: true + Layout.fillHeight: true + Text { + text: `${mixxxValue.value}` + font.pixelSize: 24 + font.family: "Noto Sans" + font.letterSpacing: -1 + color: fontColor + } + } + } + } + } + } + } + Loader { + id: loader + anchors.fill: parent + sourceComponent: splash + } +} diff --git a/src/controllers/controller.cpp b/src/controllers/controller.cpp index 61a4bf8c0025..9cca33f14ad2 100644 --- a/src/controllers/controller.cpp +++ b/src/controllers/controller.cpp @@ -44,11 +44,12 @@ void Controller::startEngine() qCWarning(m_logBase) << "Controller: Engine already exists! Restarting:"; stopEngine(); } - m_pScriptEngineLegacy = new ControllerScriptEngineLegacy(this, m_logBase); - QObject::connect(m_pScriptEngineLegacy, + m_pScriptEngineLegacy = std::make_shared(this, m_logBase); + QObject::connect(m_pScriptEngineLegacy.get(), &ControllerScriptEngineBase::beforeShutdown, this, &Controller::slotBeforeEngineShutdown); + emit engineStarted(m_pScriptEngineLegacy.get()); } void Controller::stopEngine() { @@ -57,12 +58,11 @@ void Controller::stopEngine() { qCWarning(m_logBase) << "Controller::stopEngine(): No engine exists!"; return; } - - delete m_pScriptEngineLegacy; - m_pScriptEngineLegacy = nullptr; + m_pScriptEngineLegacy.reset(); + emit engineStopped(); } -bool Controller::applyMapping() { +bool Controller::applyMapping(const QString& resourcePath) { qCInfo(m_logBase) << "Applying controller mapping..."; const std::shared_ptr pMapping = cloneMapping(); @@ -84,6 +84,13 @@ bool Controller::applyMapping() { m_pScriptEngineLegacy->setScriptFiles(scriptFiles); m_pScriptEngineLegacy->setSettings(pMapping->getSettings()); +#ifdef MIXXX_USE_QML + m_pScriptEngineLegacy->setModulePaths(pMapping->getModules()); + m_pScriptEngineLegacy->setInfoScreens(pMapping->getInfoScreens()); + m_pScriptEngineLegacy->setResourcePath(resourcePath); +#else + Q_UNUSED(resourcePath); +#endif return m_pScriptEngineLegacy->initialize(); } diff --git a/src/controllers/controller.h b/src/controllers/controller.h index 2c91e7d0ba7e..dcc7ba05032c 100644 --- a/src/controllers/controller.h +++ b/src/controllers/controller.h @@ -53,12 +53,22 @@ class Controller : public QObject { return m_bLearning; } + inline std::shared_ptr getScriptEngine() const { + return m_pScriptEngineLegacy; + } + virtual bool matchMapping(const MappingInfo& mapping) = 0; signals: /// Emitted when the controller is opened or closed. void openChanged(bool bOpen); + /// Emitted when the controller has started a new engine. + void engineStarted(const ControllerScriptEngineLegacy* engine); + + /// Emitted when the controller has stopped its engine. + void engineStopped(); + // Making these slots protected/private ensures that other parts of Mixxx can // only signal them which allows us to use no locks. protected slots: @@ -70,7 +80,7 @@ class Controller : public QObject { // this if they have an alternate way of handling such data.) virtual void receive(const QByteArray& data, mixxx::Duration timestamp); - virtual bool applyMapping(); + virtual bool applyMapping(const QString& resourcePath); virtual void slotBeforeEngineShutdown(); // Puts the controller in and out of learning mode. @@ -99,10 +109,6 @@ class Controller : public QObject { // were required to specify it. virtual void send(const QList& data, unsigned int length = 0); - // This must be reimplemented by sub-classes desiring to send raw bytes to a - // controller. - virtual void sendBytes(const QByteArray& data) = 0; - // To be called in sub-class' open() functions after opening the device but // before starting any input polling/processing. virtual void startEngine(); @@ -114,9 +120,6 @@ class Controller : public QObject { // To be called when receiving events void triggerActivity(); - inline ControllerScriptEngineLegacy* getScriptEngine() const { - return m_pScriptEngineLegacy; - } inline void setDeviceCategory(const QString& deviceCategory) { m_sDeviceCategory = deviceCategory; } @@ -136,6 +139,11 @@ class Controller : public QObject { const RuntimeLoggingCategory m_logInput; const RuntimeLoggingCategory m_logOutput; + public: + // This must be reimplemented by sub-classes desiring to send raw bytes to a + // controller. + virtual void sendBytes(const QByteArray& data) = 0; + private: // but used by ControllerManager virtual int open() = 0; @@ -151,7 +159,9 @@ class Controller : public QObject { } private: - ControllerScriptEngineLegacy* m_pScriptEngineLegacy; + /// Controllers have multiple ownership over an engine. + /// A ControllerScriptEngine must not own a controller. + std::shared_ptr m_pScriptEngineLegacy; // Verbose and unique description of device type, defaults to empty QString m_sDeviceCategory; diff --git a/src/controllers/controllerenginethreadcontrol.cpp b/src/controllers/controllerenginethreadcontrol.cpp new file mode 100644 index 000000000000..24afdd78ed5e --- /dev/null +++ b/src/controllers/controllerenginethreadcontrol.cpp @@ -0,0 +1,97 @@ +#include "controllers/controllerenginethreadcontrol.h" + +#include + +#include "moc_controllerenginethreadcontrol.cpp" +#include "util/assert.h" +#include "util/logger.h" +#include "util/mutex.h" +#include "util/thread_affinity.h" + +namespace { +const mixxx::Logger kLogger("ControllerEngineThreadControl"); +constexpr int kMaxPauseDurationMilliseconds = 1000; +} // namespace + +ControllerEngineThreadControl::ControllerEngineThreadControl(QObject* parent) + : QObject(parent) { +} +bool ControllerEngineThreadControl::pause() { + VERIFY_OR_DEBUG_ASSERT_THIS_QOBJECT_THREAD_ANTI_AFFINITY() { + return false; + } + const auto lock = lockMutex(&m_pauseMutex); + m_pauseCount++; + + if (m_canPause && !m_isPaused) { + emit pauseRequested(); + } + + while (m_canPause && !m_isPaused) { + if (!m_isPausedCondition.wait(&m_pauseMutex, kMaxPauseDurationMilliseconds)) { + kLogger.warning() << "Pause request timed out!"; + m_pauseCount--; + return false; + } + } + return !m_canPause || m_isPaused; +} +void ControllerEngineThreadControl::resume() { + VERIFY_OR_DEBUG_ASSERT_THIS_QOBJECT_THREAD_ANTI_AFFINITY() { + return; + } + const auto lock = lockMutex(&m_pauseMutex); + if (m_pauseCount > 0) { + m_pauseCount--; + } + m_isPaused = m_pauseCount > 0; + m_isPausedCondition.wakeOne(); +} +void ControllerEngineThreadControl::setCanPause(bool canPause) { + DEBUG_ASSERT_THIS_QOBJECT_THREAD_AFFINITY(); + auto lock = lockMutex(&m_pauseMutex); + m_canPause = canPause; + + if (m_canPause) { + connect(this, + &ControllerEngineThreadControl::pauseRequested, + this, + &ControllerEngineThreadControl::doPause, + Qt::UniqueConnection); + } else { + // New signals may have been queued emitted requesting for pause, so we + // manually process the event loop now to clear and handle those, before + // disabling pausing. Without this, thread requesting pause will stay + // stuck waiting on the condvar. + lock.unlock(); + QCoreApplication::processEvents(); + lock.relock(); + + disconnect(this, + &ControllerEngineThreadControl::pauseRequested, + this, + &ControllerEngineThreadControl::doPause); + + m_isPaused = false; + m_pauseCount = 0; + m_isPausedCondition.wakeOne(); + } +} +void ControllerEngineThreadControl::doPause() { + VERIFY_OR_DEBUG_ASSERT_THIS_QOBJECT_THREAD_AFFINITY() { + return; + } + const auto lock = lockMutex(&m_pauseMutex); + m_isPaused = m_pauseCount > 0; + m_isPausedCondition.wakeOne(); + + while (m_canPause && m_isPaused) { + VERIFY_OR_DEBUG_ASSERT(m_isPausedCondition.wait( + &m_pauseMutex, kMaxPauseDurationMilliseconds)) { + kLogger.warning() << "Engine pause timed out!"; + m_isPaused = false; + m_pauseCount = 0; + }; + } + m_isPausedCondition.wakeAll(); +} diff --git a/src/controllers/controllerenginethreadcontrol.h b/src/controllers/controllerenginethreadcontrol.h new file mode 100644 index 000000000000..ae5cba333f7e --- /dev/null +++ b/src/controllers/controllerenginethreadcontrol.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +/// @brief A ControllerEngineThreadControl can be used to orchestrate thread +/// pausing on a ControllerScriptEngineBase. Pausing is required by the +/// rendering thread (https://doc.qt.io/qt-6/qquickrendercontrol.html#sync). +/// This offscreen render thread is used to pause the main "GUI thread" for onboard +/// screens. +/// The Qt documentation isn't completely clear about this, but after +/// testing, it appears that the "GUI main thread" is the thread where the QML +/// engine lives in (also the main thread if we were using a +/// QMLApplication, which isn't the case here) +class ControllerEngineThreadControl : public QObject { + Q_OBJECT + public: + explicit ControllerEngineThreadControl(QObject* parent = nullptr); + + public slots: + // The following slots may be used by the rendering engine to pause the thread. + // They must be called from a different thread than ControllerEngineThreadControl's. + bool pause(); + void resume(); + + // Change whether or not it is possible to pause the thread. Should be + // called from the same thread as ControllerEngineThreadControl. + void setCanPause(bool canPause); + private slots: + // Used to effectively pause the thread. Must be called from the same thread + // as ControllerEngineThreadControl. + void doPause(); + + signals: + void pauseRequested(); + + private: + QWaitCondition m_isPausedCondition; + QMutex m_pauseMutex; + int m_pauseCount{0}; + bool m_isPaused{false}; + bool m_canPause{false}; +}; diff --git a/src/controllers/controllermanager.cpp b/src/controllers/controllermanager.cpp index 7dbac102ef32..073ee897cbb5 100644 --- a/src/controllers/controllermanager.cpp +++ b/src/controllers/controllermanager.cpp @@ -300,7 +300,7 @@ void ControllerManager::slotSetUpDevices() { qWarning() << "There was a problem opening" << name; continue; } - pController->applyMapping(); + pController->applyMapping(m_pConfig->getResourcePath()); } pollIfAnyControllersOpen(); @@ -392,7 +392,7 @@ void ControllerManager::openController(Controller* pController) { // If successfully opened the device, apply the mapping and save the // preference setting. if (result == 0) { - pController->applyMapping(); + pController->applyMapping(m_pConfig->getResourcePath()); // Update configuration to reflect controller is enabled. m_pConfig->setValue( diff --git a/src/controllers/controllerscreenpreview.cpp b/src/controllers/controllerscreenpreview.cpp new file mode 100644 index 000000000000..30b655317bb1 --- /dev/null +++ b/src/controllers/controllerscreenpreview.cpp @@ -0,0 +1,69 @@ +#include "controllers/controllerscreenpreview.h" + +#include +#include + +#include "moc_controllerscreenpreview.cpp" +#include "util/time.h" + +namespace { +/// Number of sample frame timestamp sample to perform a smooth average FPS label. +constexpr double kFrameSmoothAverageFactor = 20; +} // namespace + +using Clock = std::chrono::steady_clock; + +ControllerScreenPreview::ControllerScreenPreview( + QWidget* parent, const LegacyControllerMapping::ScreenInfo& screen) + : QWidget(parent), + m_screenInfo(screen), + m_pFrame(make_parented(this)), + m_pStat(make_parented(tr("FPS: n/a"), this)) { + m_pFrame->setFixedSize(screen.size); + setMaximumWidth(screen.size.width()); + m_pStat->setAlignment(Qt::AlignRight); + auto pLayout = make_parented(this); + auto* pBottomLayout = new QHBoxLayout(); + pLayout->addWidget(m_pFrame); + pBottomLayout->addWidget(make_parented( + QStringLiteral("\"%0\"") + .arg(m_screenInfo.identifier.isEmpty() + ? tr("Unnamed") + : m_screenInfo.identifier), + this)); + pBottomLayout->addWidget(m_pStat); + pLayout->addLayout(pBottomLayout); + pLayout->addSpacerItem(new QSpacerItem( + 1, 40, QSizePolicy::Minimum, QSizePolicy::Expanding)); +} +void ControllerScreenPreview::updateFrame( + const LegacyControllerMapping::ScreenInfo& screen, const QImage& frame) { + if (m_screenInfo.identifier != screen.identifier) { + return; + } + m_pFrame->setPixmap(QPixmap::fromImage(frame)); + + auto currentTimestamp = Clock::now(); + if (m_lastFrameTimestamp == Clock::time_point()) { + m_lastFrameTimestamp = currentTimestamp; + return; + } + + if (m_averageFrameDuration == 0) { + m_averageFrameDuration = + std::chrono::duration_cast( + currentTimestamp - m_lastFrameTimestamp) + .count(); + } else { + m_averageFrameDuration = std::lerp(m_averageFrameDuration, + std::chrono::duration_cast( + currentTimestamp - m_lastFrameTimestamp) + .count(), + 1.0 / kFrameSmoothAverageFactor); + } + m_lastFrameTimestamp = currentTimestamp; + m_pStat->setText(tr("FPS: %0/%1") + .arg(QString::number(static_cast( + 1000000 / m_averageFrameDuration)), + QString::number(m_screenInfo.target_fps))); +} diff --git a/src/controllers/controllerscreenpreview.h b/src/controllers/controllerscreenpreview.h new file mode 100644 index 000000000000..6c4ef24784a0 --- /dev/null +++ b/src/controllers/controllerscreenpreview.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include + +#include "controllers/scripting/legacy/controllerscriptenginelegacy.h" +#include "util/duration.h" +#include "util/parented_ptr.h" + +/// Widget to preview controller screen, used in preference window. This is +/// useful to help when developing new screen layout, without inducing any wear +/// and tear on a hardware device, or allow testing when not owning a device +/// using onboard screens. This can also be used to provide debugging +/// information as user can easily take a screenshot of what they see on the +/// device. +class ControllerScreenPreview : public QWidget { + Q_OBJECT + public: + ControllerScreenPreview(QWidget* parent, + const LegacyControllerMapping::ScreenInfo& screen); + public slots: + void updateFrame(const LegacyControllerMapping::ScreenInfo& screen, const QImage& frame); + + private: + LegacyControllerMapping::ScreenInfo m_screenInfo; + + parented_ptr m_pFrame; + parented_ptr m_pStat; + + double m_averageFrameDuration; + using Clock = std::chrono::steady_clock; + Clock::time_point m_lastFrameTimestamp; +}; diff --git a/src/controllers/defs_controllers.h b/src/controllers/defs_controllers.h index 7e3471671b97..5872f3953a86 100644 --- a/src/controllers/defs_controllers.h +++ b/src/controllers/defs_controllers.h @@ -25,5 +25,4 @@ inline QString userMappingsPath(UserSettingsPointer pConfig) { #define HID_MAPPING_EXTENSION ".hid.xml" #define MIDI_MAPPING_EXTENSION ".midi.xml" #define BULK_MAPPING_EXTENSION ".bulk.xml" -#define REQUIRED_SCRIPT_FILE "common-controller-scripts.js" #define XML_SCHEMA_VERSION "1" diff --git a/src/controllers/dlgprefcontroller.cpp b/src/controllers/dlgprefcontroller.cpp index 49bf0788393c..d58fbf6b6731 100644 --- a/src/controllers/dlgprefcontroller.cpp +++ b/src/controllers/dlgprefcontroller.cpp @@ -13,13 +13,18 @@ #include "controllers/controllermappinginfoenumerator.h" #include "controllers/controlleroutputmappingtablemodel.h" #include "controllers/controlpickermenu.h" +#ifdef MIXXX_USE_QML +#include "controllers/controllerscreenpreview.h" +#endif #include "controllers/defs_controllers.h" #include "controllers/dlgcontrollerlearning.h" #include "controllers/midi/legacymidicontrollermapping.h" #include "controllers/midi/midicontroller.h" +#include "controllers/scripting/legacy/controllerscriptenginelegacy.h" #include "defs_urls.h" #include "moc_dlgprefcontroller.cpp" #include "preferences/usersettings.h" +#include "util/cmdlineargs.h" #include "util/desktophelper.h" #include "util/parented_ptr.h" #include "util/string.h" @@ -33,6 +38,29 @@ QString mappingNameToPath(const QString& directory, const QString& mappingName) return directory + fileName + kMappingExt; } +const QString kBuiltinFileSuffix = + QStringLiteral(" (") + QObject::tr("built-in") + QStringLiteral(")"); + +/// @brief Format a controller file to display attributes (system, missing) in the UI. +/// @return The formatted string. +QString formatFilePath(UserSettingsPointer pConfig, + QColor linkColor, + const QString& name, + const QFileInfo& file) { + QString systemMappingPath = resourceMappingsPath(pConfig); + QString scriptFileLink = coloredLinkString( + linkColor, + name, + file.absoluteFilePath()); + if (!file.exists()) { + scriptFileLink += + QStringLiteral(" (") + QObject::tr("missing") + QStringLiteral(")"); + } else if (file.absoluteFilePath().startsWith(systemMappingPath)) { + scriptFileLink += kBuiltinFileSuffix; + } + return scriptFileLink; +} + } // namespace DlgPrefController::DlgPrefController( @@ -109,6 +137,19 @@ DlgPrefController::DlgPrefController( &ControllerManager::mappingApplied, this, &DlgPrefController::enableWizardAndIOTabs); +#ifdef MIXXX_USE_QML + if (CmdlineArgs::Instance() + .getControllerPreviewScreens()) { + connect(m_pController, + &Controller::engineStarted, + this, + &DlgPrefController::slotShowPreviewScreens); + connect(m_pController, + &Controller::engineStopped, + this, + &DlgPrefController::slotClearPreviewScreens); + } +#endif // Open script file links connect(m_ui.labelLoadedMappingScriptFileLinks, @@ -379,33 +420,23 @@ QString DlgPrefController::mappingFileLinks( return QString(); } - const QString builtinFileSuffix = QStringLiteral(" (") + tr("built-in") + QStringLiteral(")"); - QString systemMappingPath = resourceMappingsPath(m_pConfig); QStringList linkList; - QString xmlFileLink = coloredLinkString( + linkList.append(formatFilePath(m_pConfig, m_pLinkColor, QFileInfo(pMapping->filePath()).fileName(), - pMapping->filePath()); - if (pMapping->filePath().startsWith(systemMappingPath)) { - xmlFileLink += builtinFileSuffix; - } - linkList << xmlFileLink; - + QFileInfo(pMapping->filePath()))); for (const auto& script : pMapping->getScriptFiles()) { - QString scriptFileLink = coloredLinkString( + linkList.append(formatFilePath(m_pConfig, m_pLinkColor, script.name, - script.file.absoluteFilePath()); - if (!script.file.exists()) { - scriptFileLink += - QStringLiteral(" (") + tr("missing") + QStringLiteral(")"); - } else if (script.file.absoluteFilePath().startsWith( - systemMappingPath)) { - scriptFileLink += builtinFileSuffix; - } - - linkList << scriptFileLink; + QFileInfo(script.file.absoluteFilePath()))); + } +#ifdef MIXXX_USE_QML + for (const auto& qmlLibrary : pMapping->getModules()) { + auto fileInfo = QFileInfo(qmlLibrary.dirinfo.absoluteFilePath()); + linkList.append(formatFilePath(m_pConfig, m_pLinkColor, fileInfo.fileName(), fileInfo)); } +#endif return linkList.join("
"); } @@ -629,6 +660,7 @@ void DlgPrefController::slotMappingSelected(int chosenIndex) { } m_ui.groupBoxSettings->setVisible(false); + m_ui.groupBoxScreens->setVisible(false); } else { // User picked a mapping m_ui.chkEnabledDevice->setEnabled(true); @@ -832,6 +864,43 @@ void DlgPrefController::initTableView(QTableView* pTable) { pTable->setAlternatingRowColors(true); } +#ifdef MIXXX_USE_QML +void DlgPrefController::slotShowPreviewScreens( + const ControllerScriptEngineLegacy* scriptEngine) { + QLayoutItem* pItem; + VERIFY_OR_DEBUG_ASSERT(m_ui.groupBoxScreens->layout()) { + return; + } + while ((pItem = m_ui.groupBoxScreens->layout()->takeAt(0)) != nullptr) { + delete pItem->widget(); + delete pItem; + } + + if (!m_pMapping) { + return; + } + + m_ui.groupBoxScreens->setVisible( + scriptEngine != nullptr && !m_pMapping->getInfoScreens().empty()); + if (!scriptEngine) { + return; + } + + auto screens = m_pMapping->getInfoScreens(); + + for (const LegacyControllerMapping::ScreenInfo& screen : std::as_const(screens)) { + ControllerScreenPreview* pPreviewScreen = + new ControllerScreenPreview(m_ui.groupBoxScreens, screen); + m_ui.groupBoxScreens->layout()->addWidget(pPreviewScreen); + + connect(scriptEngine, + &ControllerScriptEngineLegacy::previewRenderedScreen, + pPreviewScreen, + &ControllerScreenPreview::updateFrame); + } +} +#endif + void DlgPrefController::slotShowMapping(std::shared_ptr pMapping) { m_ui.labelLoadedMapping->setText(mappingName(pMapping)); m_ui.labelLoadedMappingDescription->setText(mappingDescription(pMapping)); @@ -875,6 +944,19 @@ void DlgPrefController::slotShowMapping(std::shared_ptr m_pMapping = pMapping; } +#ifdef MIXXX_USE_QML + if (pMapping && + CmdlineArgs::Instance() + .getControllerPreviewScreens() && + pMapping && + !pMapping->getInfoScreens().isEmpty()) { + slotShowPreviewScreens(m_pController->getScriptEngine().get()); + } else +#endif + { + m_ui.groupBoxScreens->setVisible(false); + } + // Inputs tab ControllerInputMappingTableModel* pInputModel = new ControllerInputMappingTableModel(this, diff --git a/src/controllers/dlgprefcontroller.h b/src/controllers/dlgprefcontroller.h index 9a3fbd7e6ed4..292a0406afec 100644 --- a/src/controllers/dlgprefcontroller.h +++ b/src/controllers/dlgprefcontroller.h @@ -18,6 +18,9 @@ class ControllerOutputMappingTableModel; class ControlPickerMenu; class DlgControllerLearning; class MappingInfoEnumerator; +#ifdef MIXXX_USE_QML +class ControllerScriptEngineLegacy; +#endif /// Configuration dialog for a single DJ controller class DlgPrefController : public DlgPreferencePage { @@ -61,6 +64,15 @@ class DlgPrefController : public DlgPreferencePage { void slotStopLearning(); void enableWizardAndIOTabs(bool enable); +#ifdef MIXXX_USE_QML + // Onboard screen controller. + void slotShowPreviewScreens(const ControllerScriptEngineLegacy* scriptEngine); + // Wrapper used on shutdown. + void slotClearPreviewScreens() { + slotShowPreviewScreens(nullptr); + } +#endif + // Input mappings void addInputMapping(); void showLearningWizard(); diff --git a/src/controllers/dlgprefcontrollerdlg.ui b/src/controllers/dlgprefcontrollerdlg.ui index 9b296011d893..9e83f1b5cab9 100644 --- a/src/controllers/dlgprefcontrollerdlg.ui +++ b/src/controllers/dlgprefcontrollerdlg.ui @@ -6,8 +6,8 @@ 0 0 - 507 - 437 + 902 + 591 @@ -26,116 +26,16 @@ 0 + + + 0 + 0 + + Controller Setup - - - - - - 0 - 0 - - - - - - - - - - - 0 - 0 - - - - - 50 - 50 - - - - - 50 - 50 - - - - (icon) - - - - - - - - - - (Warning message goes here) - - - true - - - true - - - Qt::TextBrowserInteraction - - - - - - - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - Load Mapping: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - comboBoxMapping - - - - - - - true - - - - 0 - 0 - - - - - - - (device category goes here) - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - + @@ -152,73 +52,8 @@ - - - - Click to start the Controller Learning wizard. - - - - - - Learning Wizard (MIDI Only) - - - false - - - false - - - - - - - true - - - - 0 - 0 - - - - - 14 - 75 - true - - - - Controller Name - - - - - - - Enabled - - - - - - - Qt::Vertical - - - QSizePolicy::Expanding - - - - 20 - 40 - - - - - + Mapping Info @@ -431,6 +266,31 @@ + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + Load Mapping: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + comboBoxMapping + + + @@ -448,6 +308,160 @@ + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 40 + + + + + + + + true + + + + 0 + 0 + + + + + + + (device category goes here) + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Enabled + + + + + + + true + + + + 0 + 0 + + + + + 14 + 75 + true + + + + Controller Name + + + + + + + + 0 + 0 + + + + + + + + + + + 0 + 0 + + + + + 50 + 50 + + + + + 50 + 50 + + + + (icon) + + + + + + + + + + (Warning message goes here) + + + true + + + true + + + Qt::TextBrowserInteraction + + + + + + + + + + Click to start the Controller Learning wizard. + + + + + + Learning Wizard (MIDI Only) + + + false + + + false + + + + + + + Screens preview + + + + diff --git a/src/controllers/legacycontrollermapping.h b/src/controllers/legacycontrollermapping.h index 0e556c9a11e7..3c74c7c85889 100644 --- a/src/controllers/legacycontrollermapping.h +++ b/src/controllers/legacycontrollermapping.h @@ -4,11 +4,22 @@ #include #include +#include +#include +#include +#include +#include +#include +#include +#ifdef MIXXX_USE_QML +#include +#endif #include "controllers/legacycontrollersettings.h" #include "controllers/legacycontrollersettingslayout.h" #include "defs_urls.h" #include "preferences/usersettings.h" +#include "util/assert.h" /// This class represents a controller mapping, containing the data elements that /// make it up. @@ -35,6 +46,10 @@ class LegacyControllerMapping { m_settingsLayout(other.m_settingsLayout.get() != nullptr ? other.m_settingsLayout->clone() : nullptr), +#ifdef MIXXX_USE_QML + m_modules(other.m_modules), + m_screens(other.m_screens), +#endif m_scripts(other.m_scripts), m_deviceDirection(other.m_deviceDirection) { } @@ -43,14 +58,20 @@ class LegacyControllerMapping { virtual std::shared_ptr clone() const = 0; struct ScriptFileInfo { - ScriptFileInfo() - : builtin(false) { - } - - QString name; - QString functionPrefix; - QFileInfo file; - bool builtin; + enum class Type { + Javascript, +#ifdef MIXXX_USE_QML + Qml, +#endif + }; + + QString name; // Name of the script file to add. + QString identifier; // The script's function prefix with Javascript OR + // the screen identifier this QML should be run for + // (or empty string). + QFileInfo file; // A FileInfo object pointing to the script file. + Type type; // A ScriptFileInfo::Type the specify script file type. + bool builtin; // If this is true, the script won't be written to the XML. }; // TODO (xxx): this is a temporary solution to address devices that don't @@ -66,21 +87,46 @@ class LegacyControllerMapping { }; Q_DECLARE_FLAGS(DeviceDirections, DeviceDirection) +#ifdef MIXXX_USE_QML + struct QMLModuleInfo { + QMLModuleInfo(const QFileInfo& aDirinfo, + bool isBuiltin) + : dirinfo(aDirinfo), + builtin(isBuiltin) { + } + + QFileInfo dirinfo; + bool builtin; + }; + + struct ScreenInfo { + // Defining a custom enum here as std::endian contains `native` which is + // confusing and will have unpredictable behaviour depending of the + // platform. + enum class ColorEndian { + Big = static_cast(std::endian::big), + Little = static_cast(std::endian::little), + }; + + QString identifier; // The screen identifier. + QSize size; // The size of the screen. + uint target_fps; // The maximum FPS to render. + uint msaa; // The MSAA value to use for render. + std::chrono::milliseconds + splash_off; // The rendering grace time given when the screen is + // requested to shutdown. + QImage::Format pixelFormat; // The pixel encoding format. + ColorEndian endian; // The pixel endian format. + bool reversedColor; // Whether or not the RGB is swapped BGR. + bool rawData; // Whether or not the screen is allowed to receive bare + // data, not transformed. + }; +#endif + /// Adds a script file to the list of controller scripts for this mapping. - /// @param filename Name of the script file to add - /// @param functionprefix The script's function prefix (or empty string) - /// @param file A FileInfo object pointing to the script file - /// @param builtin If this is true, the script won't be written to the XML - void addScriptFile(const QString& name, - const QString& functionprefix, - const QFileInfo& file, - bool builtin = false) { - ScriptFileInfo info; - info.name = name; - info.functionPrefix = functionprefix; - info.file = file; - info.builtin = builtin; - m_scripts.append(info); + /// @param info The script info to add. + virtual void addScriptFile(ScriptFileInfo info) { + m_scripts.append(std::move(info)); setDirty(true); } @@ -144,6 +190,42 @@ class LegacyControllerMapping { return m_deviceDirection; } +#ifdef MIXXX_USE_QML + /// Adds a custom QML module file to the list of controller modules for this mapping. + /// @param dirInfo A FileInfo of the directory or QML module + /// @param builtin If this is true, the script won't be written to the XML + virtual void addModule(const QFileInfo& dirInfo, + bool builtin = false) { + m_modules.append(QMLModuleInfo( + dirInfo, + builtin)); + setDirty(true); + } + + const QList& getModules() const { + return m_modules; + } + + /// @brief Adds a screen info where QML will be rendered. + /// @param identifier The screen identifier + /// @param size the size of the screen + /// @param targetFps the maximum FPS to render + /// @param msaa the MSAA value to use for render + /// @param splashoff the rendering grace time given when the screen is requested to shutdown + /// @param pixelFormat the pixel encoding format + /// @param endian the pixel endian format + /// @param reversedColor whether or not the RGB is swapped BGR + /// @param rawData whether or not the screen is allowed to reserve bare data, not transformed + virtual void addScreenInfo(ScreenInfo info) { + m_screens.append(std::move(info)); + setDirty(true); + } + + const QList& getInfoScreens() const { + return m_screens; + } +#endif + void setDirty(bool bDirty) { m_bDirty = bDirty; } @@ -289,6 +371,10 @@ class LegacyControllerMapping { QList> m_settings; std::unique_ptr m_settingsLayout; +#ifdef MIXXX_USE_QML + QList m_modules; + QList m_screens; +#endif QList m_scripts; DeviceDirections m_deviceDirection; }; diff --git a/src/controllers/legacycontrollermappingfilehandler.cpp b/src/controllers/legacycontrollermappingfilehandler.cpp index b8c6ab94321b..7782ec8186ca 100644 --- a/src/controllers/legacycontrollermappingfilehandler.cpp +++ b/src/controllers/legacycontrollermappingfilehandler.cpp @@ -2,13 +2,67 @@ #include "controllers/defs_controllers.h" #include "controllers/midi/legacymidicontrollermappingfilehandler.h" +#include "util/logger.h" #include "util/xml.h" #ifdef __HID__ #include "controllers/hid/legacyhidcontrollermappingfilehandler.h" #endif +#ifdef MIXXX_USE_QML +QMap LegacyControllerMappingFileHandler::kSupportedPixelFormat = { + {"RBG", QImage::Format_RGB888}, + {"RBGA", QImage::Format_RGBA8888}, + {"RGB565", QImage::Format_RGB16}, +}; + +QMap + LegacyControllerMappingFileHandler::kEndianFormat = { + {"big", LegacyControllerMapping::ScreenInfo::ColorEndian::Big}, + {"little", + LegacyControllerMapping::ScreenInfo::ColorEndian:: + Little}, +}; +#endif namespace { +const mixxx::Logger kLogger("LegacyControllerMappingFileHandler"); + +const QString kRequiredScriptFile = QStringLiteral("common-controller-scripts.js"); + +#ifdef MIXXX_USE_QML + +/// Find a module directory (QML) in the mapping or system path. +/// +/// @param mapping The controller mapping the module directory belongs to. +/// @param dirname The module directory name. +/// @param systemMappingsPath The system mappings path to use as fallback. +/// @return Returns a QFileInfo object. If the script was not found in either +/// of the search directories, the QFileInfo object might point to a +/// non-existing file. +QFileInfo findLibraryPath( + const LegacyControllerMapping& mapping, + const QString& dirname, + const QDir& systemMappingsPath) { + // Always try to load module directory from the mapping's directory first + QFileInfo dir = QFileInfo(mapping.dirPath().absoluteFilePath(dirname)); + + // If the module directory does not exist, try to find it in the fallback dir + if (!dir.isDir()) { + dir = QFileInfo(systemMappingsPath.absoluteFilePath(dirname)); + } + return dir; +} + +/// @brief Parse a string that contain a boolean value in human representation +/// @param value the string containing the boolean setting +/// @return true for string value "true", false otherwise +bool parseHumanBoolean(const QString& value, bool* ok) { + if (ok) { + *ok = value == QStringLiteral("true") || value == QStringLiteral("false"); + } + return value == QStringLiteral("true"); +} +#endif /// Find script file in the mapping or system path. /// @@ -31,6 +85,102 @@ QFileInfo findScriptFile(std::shared_ptr mapping, return file; } +#ifdef MIXXX_USE_QML +#define LOG_IF_NOT_OK(FIELD, TYPE) \ + if (!ok) { \ + kLogger.warning().nospace() \ + << "Unable to parse the field \"" << FIELD << "\" as " << TYPE \ + << " in the screen definition."; \ + } +/// @brief Parse the screen info from the XML definition and add it to the mapping object +/// @param screen the screen XML definition +/// @param mapping the mapping being parsed +/// @return true if the screen definition could be parse, false otherwise +bool parseAndAddScreenDefinition(const QDomElement& screen, LegacyControllerMapping* mapping) { + bool ok; + QString identifier = screen.attribute("identifier", ""); + uint targetFps = screen.attribute("targetFps", "30").toUInt(&ok); + LOG_IF_NOT_OK("targetFps", "an unsigned integer"); + uint msaa = screen.attribute("msaa", "1").toUInt(&ok); + LOG_IF_NOT_OK("msaa", "an unsigned integer"); + QString pixelFormatName = screen.attribute("pixelType", "RBG"); + QString endianName = screen.attribute("endian", "little"); + bool reversedColor = parseHumanBoolean( + screen.attribute("reversed", "false").toLower().trimmed(), &ok); + LOG_IF_NOT_OK("reversed", "a boolean"); + bool rawData = parseHumanBoolean(screen.attribute("raw", "false").toLower().trimmed(), &ok); + LOG_IF_NOT_OK("raw", "a boolean"); + uint splashOff = screen.attribute("splashoff", "0").toUInt(&ok); + LOG_IF_NOT_OK("splashoff", "an unsigned integer"); + + if (!targetFps || targetFps > LegacyControllerMappingFileHandler::kMaxTargetFps) { + kLogger.warning() + << "Invalid target FPS. Target FPS must be between 1 and" + << LegacyControllerMappingFileHandler::kMaxTargetFps; + return false; + } + if (!msaa || msaa > LegacyControllerMappingFileHandler::kMaxMsaa) { + kLogger.warning() + << "Invalid MSAA value. MSAA value must be between 1 and" + << LegacyControllerMappingFileHandler::kMaxMsaa; + return false; + } + + if (splashOff > LegacyControllerMappingFileHandler::kMaxSplashOffDuration) { + kLogger.warning() + << QString("Invalid splashoff duration. Splashoff duration " + "must be " + "between 0 and %1. Clamping to %2") + .arg(QString::number( + LegacyControllerMappingFileHandler:: + kMaxSplashOffDuration), + QString::number( + LegacyControllerMappingFileHandler:: + kMaxSplashOffDuration)); + splashOff = LegacyControllerMappingFileHandler::kMaxSplashOffDuration; + } + + if (!LegacyControllerMappingFileHandler::kSupportedPixelFormat.contains(pixelFormatName)) { + kLogger.warning() << "Unsupported pixel format" << pixelFormatName; + return false; + } + + if (!LegacyControllerMappingFileHandler::kEndianFormat.contains(endianName)) { + kLogger.warning() << "Unknown endian format" << endianName; + return false; + } + + QImage::Format pixelFormat = + LegacyControllerMappingFileHandler::kSupportedPixelFormat.value( + pixelFormatName); + auto endian = LegacyControllerMappingFileHandler::kEndianFormat.value(endianName); + + uint width = screen.attribute("width", "0").toUInt(&ok); + LOG_IF_NOT_OK("width", "an unsigned integer"); + uint height = screen.attribute("height", "0").toUInt(&ok); + LOG_IF_NOT_OK("height", "an unsigned integer"); + + if (!width || !height) { + kLogger.warning() << "Invalid screen size. Screen size must have a width " + "and height above 1 pixel"; + return false; + } + + kLogger.debug() << "Adding screen" << identifier; + mapping->addScreenInfo(LegacyControllerMapping::ScreenInfo{ + identifier, + QSize(width, height), + targetFps, + msaa, + std::chrono::milliseconds(splashOff), + pixelFormat, + endian, + reversedColor, + rawData}); + return true; +} +#endif + } // namespace // static @@ -227,20 +377,68 @@ void LegacyControllerMappingFileHandler::addScriptFilesToMapping( .firstChildElement("file"); // Default currently required file - mapping->addScriptFile(REQUIRED_SCRIPT_FILE, + mapping->addScriptFile(LegacyControllerMapping::ScriptFileInfo{ + kRequiredScriptFile, "", - findScriptFile(mapping, REQUIRED_SCRIPT_FILE, systemMappingsPath), - true); + findScriptFile(mapping, kRequiredScriptFile, systemMappingsPath), + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true}); // Look for additional ones while (!scriptFile.isNull()) { - QString functionPrefix = scriptFile.attribute("functionprefix", ""); QString filename = scriptFile.attribute("filename", ""); QFileInfo file = findScriptFile(mapping, filename, systemMappingsPath); - - mapping->addScriptFile(filename, functionPrefix, file); + if (file.suffix() == "qml") { +#ifdef MIXXX_USE_QML + QString identifier = scriptFile.attribute("identifier", ""); + mapping->addScriptFile(LegacyControllerMapping::ScriptFileInfo{ + filename, + identifier, + file, + LegacyControllerMapping::ScriptFileInfo::Type::Qml, + false}); +#else + kLogger.warning() + << "Unsupported render scene for file" << file.filePath() + << ". Mixxx isn't built with QML support"; + return; +#endif + } else { + QString functionPrefix = scriptFile.attribute("functionprefix", ""); + mapping->addScriptFile(LegacyControllerMapping::ScriptFileInfo{filename, + functionPrefix, + file, + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + false}); + } scriptFile = scriptFile.nextSiblingElement("file"); } + +#ifdef MIXXX_USE_QML + // Build a list of QML files to load + QDomElement screen = controller.firstChildElement("screens") + .firstChildElement("screen"); + + // Look for additional ones + while (!screen.isNull()) { + if (!parseAndAddScreenDefinition(screen, mapping.get())) { + return; + } + screen = screen.nextSiblingElement("screen"); + } + // Build a list of QML files to load + QDomElement qmlLibrary = controller.firstChildElement("qmllibraries") + .firstChildElement("library"); + + // Look for additional ones + while (!qmlLibrary.isNull()) { + QString libFilename = qmlLibrary.attribute("path", ""); + QFileInfo path = findLibraryPath(*mapping, libFilename, systemMappingsPath); + kLogger.debug() << "Adding QML directory " << libFilename; + mapping->addModule(path); + qmlLibrary = qmlLibrary.nextSiblingElement("library"); + } +#endif } bool LegacyControllerMappingFileHandler::writeDocument( @@ -323,8 +521,8 @@ QDomDocument LegacyControllerMappingFileHandler::buildRootWithScripts( if (script.builtin) { continue; } - qDebug() << "writing script block for" << filename; - QString functionPrefix = script.functionPrefix; + kLogger.debug() << "writing script block for" << filename; + QString functionPrefix = script.identifier; QDomElement scriptFile = doc.createElement("file"); scriptFile.setAttribute("filename", filename); diff --git a/src/controllers/legacycontrollermappingfilehandler.h b/src/controllers/legacycontrollermappingfilehandler.h index 73e5eea584c3..383585f0eda9 100644 --- a/src/controllers/legacycontrollermappingfilehandler.h +++ b/src/controllers/legacycontrollermappingfilehandler.h @@ -3,10 +3,18 @@ #include #include #include +#ifdef MIXXX_USE_QML +#include +#include +#include + +#include "controllers/legacycontrollermapping.h" +#else +class LegacyControllerMapping; +#endif class QFileInfo; class QDir; -class LegacyControllerMapping; class LegacyControllerSettingsLayoutContainer; /// The LegacyControllerMappingFileHandler is used for serializing/deserializing the @@ -48,10 +56,10 @@ class LegacyControllerMappingFileHandler { void parseMappingSettings(const QDomElement& root, LegacyControllerMapping* mapping) const; - /// Adds script files from XML to the LegacyControllerMapping. + /// Adds script files and QML scenes from XML to the LegacyControllerMapping. /// /// This function parses the supplied QDomElement structure, finds the - /// matching script files inside the search paths and adds them to + /// matching script files and QML scenes inside the search paths and adds them to /// LegacyControllerMapping. /// /// @param root The root node of the XML document for the mapping. @@ -84,5 +92,18 @@ class LegacyControllerMappingFileHandler { const QString& filePath, const QDir& systemMappingPath) = 0; +#ifdef MIXXX_USE_QML + public: + static QMap kSupportedPixelFormat; + static QMap kEndianFormat; + // Maximum target frame per request for a screen controller + static constexpr int kMaxTargetFps = 240; + // Maximum MSAA value that can be used + static constexpr int kMaxMsaa = 16; + // Maximum time allowed for a screen to run a splash off animation + static constexpr int kMaxSplashOffDuration = 3000; + + friend class ControllerRenderingEngineTest; +#endif friend class LegacyControllerMappingSettingsTest_parseSettingBlock_Test; }; diff --git a/src/controllers/midi/midicontroller.cpp b/src/controllers/midi/midicontroller.cpp index f815cf5d0d2f..3130f6594fed 100644 --- a/src/controllers/midi/midicontroller.cpp +++ b/src/controllers/midi/midicontroller.cpp @@ -76,9 +76,9 @@ bool MidiController::matchMapping(const MappingInfo& mapping) { return false; } -bool MidiController::applyMapping() { +bool MidiController::applyMapping(const QString& resourcePath) { // Handles the engine - bool result = Controller::applyMapping(); + bool result = Controller::applyMapping(resourcePath); // Only execute this code if this is an output device if (isOutputDevice()) { @@ -301,7 +301,7 @@ void MidiController::processInputMapping(const MidiInputMapping& mapping, MidiOpCode opCode = MidiUtils::opCodeFromStatus(status); if (mapping.options.testFlag(MidiOption::Script)) { - ControllerScriptEngineLegacy* pEngine = getScriptEngine(); + auto pEngine = getScriptEngine(); if (pEngine == nullptr) { return; } @@ -595,7 +595,7 @@ void MidiController::processInputMapping(const MidiInputMapping& mapping, mixxx::Duration timestamp) { // Custom script handler if (mapping.options.testFlag(MidiOption::Script)) { - ControllerScriptEngineLegacy* pEngine = getScriptEngine(); + auto pEngine = getScriptEngine(); if (pEngine == nullptr) { return; } diff --git a/src/controllers/midi/midicontroller.h b/src/controllers/midi/midicontroller.h index dab0a4dd3cd4..fabc0df85ab7 100644 --- a/src/controllers/midi/midicontroller.h +++ b/src/controllers/midi/midicontroller.h @@ -81,7 +81,7 @@ class MidiController : public Controller { void slotBeforeEngineShutdown() override; private slots: - bool applyMapping() override; + bool applyMapping(const QString& resourcePath) override; void learnTemporaryInputMappings(const MidiInputMappings& mappings); void clearTemporaryInputMappings(); diff --git a/src/controllers/rendering/controllerrenderingengine.cpp b/src/controllers/rendering/controllerrenderingengine.cpp new file mode 100644 index 000000000000..d2c19a0ac7aa --- /dev/null +++ b/src/controllers/rendering/controllerrenderingengine.cpp @@ -0,0 +1,391 @@ +#include "controllers/rendering/controllerrenderingengine.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "controllers/controller.h" +#include "controllers/controllerenginethreadcontrol.h" +#include "controllers/scripting/legacy/controllerscriptenginelegacy.h" +#include "controllers/scripting/legacy/controllerscriptinterfacelegacy.h" +#include "moc_controllerrenderingengine.cpp" +#include "qml/qmlwaveformoverview.h" +#include "util/cmdlineargs.h" +#include "util/logger.h" +#include "util/thread_affinity.h" +#include "util/time.h" +#include "util/timer.h" + +// Used in the renderFrame method to properly abort the rendering and terminate the engine. +#define VERIFY_OR_TERMINATE(cond, msg) \ + VERIFY_OR_DEBUG_ASSERT(cond) { \ + kLogger.warning() << msg; \ + m_pThread->quit(); \ + return; \ + } + +namespace { +const mixxx::Logger kLogger("ControllerRenderingEngine"); +} // anonymous namespace + +using Clock = std::chrono::steady_clock; + +ControllerRenderingEngine::ControllerRenderingEngine( + const LegacyControllerMapping::ScreenInfo& info, + gsl::not_null engineThreadControl) + : QObject(), + m_screenInfo(info), + m_GLDataFormat(GL_RGBA), + m_GLDataType(GL_UNSIGNED_BYTE), + m_isValid(true), + m_pEngineThreadControl(engineThreadControl) { + switch (m_screenInfo.pixelFormat) { + case QImage::Format_RGB16: + m_GLDataFormat = GL_RGB; + m_GLDataType = m_screenInfo.reversedColor + ? GL_UNSIGNED_SHORT_5_6_5_REV + : GL_UNSIGNED_SHORT_5_6_5; + break; + case QImage::Format_RGB888: + m_GLDataFormat = m_screenInfo.reversedColor ? GL_BGR : GL_RGB; + m_GLDataType = GL_UNSIGNED_BYTE; + break; + case QImage::Format_RGBA8888: + m_GLDataFormat = m_screenInfo.reversedColor ? GL_BGRA : GL_RGBA; + m_GLDataType = GL_UNSIGNED_BYTE; + break; + default: + m_isValid = false; + DEBUG_ASSERT(!"Unsupported format"); + } + + if (!m_isValid) { + return; + } + + prepare(); +} + +void ControllerRenderingEngine::prepare() { + m_pThread = std::make_unique(); + m_pThread->setObjectName("ControllerScreenRenderer"); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + [[maybe_unused]] bool successful = moveToThread(m_pThread.get()); + DEBUG_ASSERT(successful); +#else + moveToThread(m_pThread.get()); +#endif + + // these at first sight weird-looking connections facilitate thread-safe communication. + connect(this, + &ControllerRenderingEngine::sendFrameDataRequested, + this, + &ControllerRenderingEngine::send); + connect(m_pThread.get(), + &QThread::finished, + this, + &ControllerRenderingEngine::finish); + + m_pThread->start(QThread::NormalPriority); +} + +ControllerRenderingEngine::~ControllerRenderingEngine() { + DEBUG_ASSERT_THIS_QOBJECT_THREAD_ANTI_AFFINITY(); + m_pThread->wait(); + VERIFY_OR_DEBUG_ASSERT(!m_fbo) { + kLogger.critical() << "The ControllerEngine is being deleted but hasn't been " + "cleaned up. Brace for impact"; + }; +} + +void ControllerRenderingEngine::start() { + VERIFY_OR_DEBUG_ASSERT(!thread()->isFinished() && !thread()->isInterruptionRequested()) { + kLogger.critical() << "Render thread has or is about to terminate. Cannot " + "start this render anymore."; + return; + } + QCoreApplication::postEvent(this, new QEvent(QEvent::UpdateRequest)); +} +bool ControllerRenderingEngine::isRunning() const { + return m_pThread && m_pThread->isRunning(); +} + +void ControllerRenderingEngine::requestEngineSetup(std::shared_ptr qmlEngine) { + DEBUG_ASSERT(!m_quickWindow); + VERIFY_OR_DEBUG_ASSERT(qmlEngine) { + kLogger.critical() << "No QML engine was passed!"; + return; + } + VERIFY_OR_DEBUG_ASSERT_THIS_QOBJECT_THREAD_ANTI_AFFINITY() { + kLogger.warning() << "Unable to setup OpenGL rendering context from the same " + "thread as the render object"; + return; + } + + QMetaObject::invokeMethod( + this, + [this, qmlEngine]() { + setup(qmlEngine); + }, + // This invocation will block the current thread! + Qt::BlockingQueuedConnection); + + if (m_quickWindow) { + m_renderControl->prepareThread(m_pThread.get()); + } +} + +void ControllerRenderingEngine::requestSendingFrameData( + Controller* controller, const QByteArray& frame) { + emit sendFrameDataRequested(controller, frame); +} + +void ControllerRenderingEngine::setup(std::shared_ptr qmlEngine) { + VERIFY_OR_DEBUG_ASSERT_THIS_QOBJECT_THREAD_AFFINITY() { + kLogger.warning() << "The ControllerRenderingEngine setup must be done by its own thread!"; + return; + } + QSurfaceFormat format; + format.setSamples(m_screenInfo.msaa); + format.setDepthBufferSize(16); + format.setStencilBufferSize(8); + + m_context = std::make_unique(); + m_context->setFormat(format); + VERIFY_OR_DEBUG_ASSERT(m_context->create()) { + kLogger.warning() << "Unable to initialize controller screen rendering. Giving up"; + return; + } + connect(m_context.get(), + &QOpenGLContext::aboutToBeDestroyed, + this, + &ControllerRenderingEngine::finish); + + m_offscreenSurface = std::make_unique(); + m_offscreenSurface->setFormat(m_context->format()); + + // offscreen surface needs to be created from application main thread. + VERIFY_OR_DEBUG_ASSERT(QMetaObject::invokeMethod( + qApp, + [this] { + m_offscreenSurface->create(); + }, + // This invocation will block the current thread! + Qt::BlockingQueuedConnection) && + m_offscreenSurface->isValid()) { + kLogger.warning() << "Unable to create the OffscreenSurface for controller " + "screen rendering. Giving up"; + m_offscreenSurface.reset(); + return; + } + + m_renderControl = std::make_unique(this); + m_quickWindow = std::make_unique(m_renderControl.get()); + + if (!qmlEngine->incubationController()) { + qmlEngine->setIncubationController(m_quickWindow->incubationController()); + } + + m_quickWindow->setGeometry(0, 0, m_screenInfo.size.width(), m_screenInfo.size.height()); +} + +void ControllerRenderingEngine::finish() { + DEBUG_ASSERT_THIS_QOBJECT_THREAD_AFFINITY(); + emit stopping(); + + m_isValid = false; + + if (m_context && m_context->isValid()) { + disconnect(m_context.get(), + &QOpenGLContext::aboutToBeDestroyed, + this, + &ControllerRenderingEngine::finish); + m_context->makeCurrent(m_offscreenSurface.get()); + m_renderControl.reset(); + + std::shared_ptr pOffscreenSurface = std::move(m_offscreenSurface); + QMetaObject::invokeMethod( + qApp, + [pOffscreenSurface] { + pOffscreenSurface->destroy(); + }); + m_quickWindow.reset(); + + // Free the engine and FBO. + m_fbo.reset(); + + m_context->doneCurrent(); + } + m_context.reset(); +} + +void ControllerRenderingEngine::renderFrame() { + ScopedTimer t(QStringLiteral("ControllerRenderingEngine::renderFrame")); + if (!m_isValid) { + DEBUG_ASSERT(!"Trying to render frame on an invalid engine"); + return; + } + + VERIFY_OR_TERMINATE(m_offscreenSurface->isValid(), "OffscreenSurface isn't valid anymore."); + VERIFY_OR_TERMINATE(m_context->isValid(), "GLContext isn't valid anymore."); + VERIFY_OR_TERMINATE(m_context->makeCurrent(m_offscreenSurface.get()), + "Couldn't make the GLContext current to the OffscreenSurface."); + + if (!m_fbo) { + ScopedTimer t(QStringLiteral("ControllerRenderingEngine::renderFrame::initFBO")); + VERIFY_OR_TERMINATE( + QOpenGLFramebufferObject::hasOpenGLFramebufferObjects(), + "OpenGL doesn't support FBO"); + + m_fbo = std::make_unique( + m_screenInfo.size, QOpenGLFramebufferObject::CombinedDepthStencil); + + GLenum glError; + glError = m_context->functions()->glGetError(); + + VERIFY_OR_TERMINATE(glError == GL_NO_ERROR, "GLError: " << glError); + VERIFY_OR_TERMINATE(m_fbo->isValid(), "Failed to initialize FBO"); + + m_quickWindow->setGraphicsDevice(QQuickGraphicsDevice::fromOpenGLContext(m_context.get())); + + VERIFY_OR_TERMINATE(m_renderControl->initialize(), + "Failed to initialize redirected Qt Quick rendering"); + + m_quickWindow->setRenderTarget(QQuickRenderTarget::fromOpenGLTexture(m_fbo->texture(), + m_screenInfo.size)); + + m_quickWindow->setGeometry(0, 0, m_screenInfo.size.width(), m_screenInfo.size.height()); + } + + m_nextFrameStart = Clock::now(); + + m_renderControl->beginFrame(); + + if (m_pEngineThreadControl) { + m_pEngineThreadControl->pause(); + } + + m_renderControl->polishItems(); + + { + ScopedTimer t(QStringLiteral("ControllerRenderingEngine::renderFrame::sync")); + VERIFY_OR_DEBUG_ASSERT(m_renderControl->sync()) { + kLogger.warning() << "Couldn't sync the render control. Scene may be stuck"; + }; + } + + if (m_pEngineThreadControl) { + m_pEngineThreadControl->resume(); + } + QImage fboImage(m_screenInfo.size, m_screenInfo.pixelFormat); + + VERIFY_OR_DEBUG_ASSERT(m_fbo->bind()) { + kLogger.warning() << "Couldn't bind the FBO."; + } + GLenum glError; + m_context->functions()->glFlush(); + glError = m_context->functions()->glGetError(); + VERIFY_OR_TERMINATE(glError == GL_NO_ERROR, "GLError: " << glError); + if (static_cast(m_screenInfo.endian) != std::endian::native) { + m_context->functions()->glPixelStorei(GL_PACK_SWAP_BYTES, GL_TRUE); + } + glError = m_context->functions()->glGetError(); + VERIFY_OR_TERMINATE(glError == GL_NO_ERROR, "GLError: " << glError); + + QDateTime timestamp = QDateTime::currentDateTime(); + m_renderControl->render(); + m_renderControl->endFrame(); + + // Flush any remaining GL errors. + while ((glError = m_context->functions()->glGetError()) != GL_NO_ERROR) { + kLogger.debug() << "Retrieved a previously unhandled GL error: " << glError; + } + { + ScopedTimer t(QStringLiteral("ControllerRenderingEngine::renderFrame::glReadPixels")); + m_context->functions()->glReadPixels(0, + 0, + m_screenInfo.size.width(), + m_screenInfo.size.height(), + m_GLDataFormat, + m_GLDataType, + fboImage.bits()); + } + glError = m_context->functions()->glGetError(); + VERIFY_OR_TERMINATE(glError == GL_NO_ERROR, "GLError: " << glError); + VERIFY_OR_DEBUG_ASSERT(!fboImage.isNull()) { + kLogger.warning() << "Screen frame is null!"; + } + VERIFY_OR_DEBUG_ASSERT(m_fbo->release()) { + kLogger.debug() << "Couldn't release the FBO."; + } + + fboImage.mirror(false, true); + + emit frameRendered(m_screenInfo, fboImage.copy(), timestamp); + + m_context->doneCurrent(); +} + +bool ControllerRenderingEngine::stop() { + m_pThread->quit(); + return m_pThread->wait(); +} + +void ControllerRenderingEngine::send(Controller* controller, const QByteArray& frame) { + DEBUG_ASSERT_THIS_QOBJECT_THREAD_AFFINITY(); + ScopedTimer t(QStringLiteral("ControllerRenderingEngine::send")); + if (!frame.isEmpty()) { + controller->sendBytes(frame); + } + + if (CmdlineArgs::Instance() + .getControllerDebug()) { + auto endOfFrameCycle = Clock::now(); + kLogger.debug() + << "Frame took " + << std::chrono::duration_cast( + endOfFrameCycle - m_nextFrameStart) + .count() + << "milliseconds and frame has" << frame.size() << "bytes"; + } + + m_nextFrameStart += std::chrono::microseconds(1000000 / m_screenInfo.target_fps); + + auto durationToWaitBeforeFrame = + std::chrono::duration_cast( + m_nextFrameStart - Clock::now()); + + if (durationToWaitBeforeFrame > std::chrono::milliseconds(0)) { + if (CmdlineArgs::Instance() + .getControllerDebug()) { + kLogger.debug() << "Waiting for " + << durationToWaitBeforeFrame.count() + << "milliseconds before rendering next frame"; + } + QTimer::singleShot(durationToWaitBeforeFrame, + Qt::PreciseTimer, + this, + &ControllerRenderingEngine::renderFrame); + } else { + QCoreApplication::postEvent(this, new QEvent(QEvent::UpdateRequest)); + } +} + +bool ControllerRenderingEngine::event(QEvent* event) { + // In case there is a request for update (e.g using QWindow::requestUpdate), + // we emit the signal to request rendering using the engine. + if (event->type() == QEvent::UpdateRequest) { + renderFrame(); + return true; + } + + return QObject::event(event); +} diff --git a/src/controllers/rendering/controllerrenderingengine.h b/src/controllers/rendering/controllerrenderingengine.h new file mode 100644 index 000000000000..770e2b722716 --- /dev/null +++ b/src/controllers/rendering/controllerrenderingengine.h @@ -0,0 +1,109 @@ +#pragma once + +#include + +#include +#include +#include + +#include "controllers/legacycontrollermapping.h" +#include "preferences/configobject.h" +#include "util/time.h" + +class Controller; +class ControllerEngineThreadControl; +class QOffscreenSurface; +class QOpenGLContext; +class QOpenGLFramebufferObject; +class QQmlEngine; +class QQuickRenderControl; +class QQuickWindow; +class QThread; + +/// @brief This class is used to host the rendering of a screen controller, +/// using and existing QML Engine running under a ControllerScriptEngineBase. +class ControllerRenderingEngine : public QObject { + Q_OBJECT + public: + ControllerRenderingEngine(const LegacyControllerMapping::ScreenInfo& info, + gsl::not_null engineThreadControl); + // Destructor will wait for the ControllerRenderingEngine's thread to + // complete. It should be called from the Controller thread. + ~ControllerRenderingEngine(); + + bool event(QEvent* event) override; + + QSize screenSize() const { + return m_screenInfo.size; + } + + bool isValid() const { + return m_isValid; + } + + bool isRunning() const; + + // pointer lives as long as the `ControllerRenderingEngine` instance it is retrieved from. + QQuickWindow* quickWindow() const { + return m_quickWindow.get(); + } + + const LegacyControllerMapping::ScreenInfo& info() const { + return m_screenInfo; + } + + public slots: + // Request sending frame data to the device. The task will be run in the + // rendering event loop. This method should only be called once received the + // `frameRendered` signal. + virtual void requestSendingFrameData(Controller* controller, const QByteArray& frame); + // Request setting up the rendering context for QML engine and wait till it + // is completed. The task will be run in the rendering event loop to ensure + // thread affinity of engine components. `isValid` can be used to ensure + // that the setup was successful. + void requestEngineSetup(std::shared_ptr qmlEngine); + void start(); + virtual bool stop(); + + private slots: + void finish(); + void renderFrame(); + void setup(std::shared_ptr qmlEngine); + void send(Controller* controller, const QByteArray& frame); + + signals: + void frameRendered(const LegacyControllerMapping::ScreenInfo& screeninfo, + QImage frame, + const QDateTime& timestamp); + void stopping(); + /// @brief Request the screen thread to send a frame to the device. + /// @param controller the controller to send the frame to. + /// @param frame the frame data, ready to be sent. + void sendFrameDataRequested(Controller* controller, const QByteArray& frame); + + private: + virtual void prepare(); + + std::chrono::time_point m_nextFrameStart; + + LegacyControllerMapping::ScreenInfo m_screenInfo; + + std::unique_ptr m_pThread; + + std::unique_ptr m_context; + std::unique_ptr m_offscreenSurface; + std::unique_ptr m_renderControl; + std::unique_ptr m_quickWindow; + + std::unique_ptr m_fbo; + + GLenum m_GLDataFormat; + GLenum m_GLDataType; + + bool m_isValid; + // Engine control is owned by ControllerScriptEngineBase. The assumption is + // made that ControllerScriptEngineBase always outlive + // ControllerRenderingEngine as it is in charge of stopping and joining the + // thread. + gsl::not_null m_pEngineThreadControl; +}; diff --git a/src/controllers/scripting/controllerscriptenginebase.cpp b/src/controllers/scripting/controllerscriptenginebase.cpp index eca4c67d6696..e31faad90887 100644 --- a/src/controllers/scripting/controllerscriptenginebase.cpp +++ b/src/controllers/scripting/controllerscriptenginebase.cpp @@ -5,7 +5,14 @@ #include "controllers/controller.h" #include "controllers/scripting/colormapperjsproxy.h" #include "errordialoghandler.h" +#ifdef MIXXX_USE_QML +#include +#endif + #include "moc_controllerscriptenginebase.cpp" +#ifdef MIXXX_USE_QML +#include "qml/asyncimageprovider.h" +#endif #include "util/cmdlineargs.h" ControllerScriptEngineBase::ControllerScriptEngineBase( @@ -15,11 +22,27 @@ ControllerScriptEngineBase::ControllerScriptEngineBase( m_pController(controller), m_logger(logger), m_bAbortOnWarning(false), +#ifdef MIXXX_USE_QML + m_bQmlMode(false), +#endif m_bTesting(false) { // Handle error dialog buttons qRegisterMetaType("QMessageBox::StandardButton"); } +#ifdef MIXXX_USE_QML +void ControllerScriptEngineBase::registerTrackCollectionManager( + std::shared_ptr pTrackCollectionManager) { + s_pTrackCollectionManager = std::move(pTrackCollectionManager); +} + +void ControllerScriptEngineBase::handleQMLErrors(const QList& qmlErrors) { + for (const QQmlError& error : std::as_const(qmlErrors)) { + showQMLExceptionDialog(error, m_bErrorsAreFatal); + } +} +#endif + bool ControllerScriptEngineBase::initialize() { VERIFY_OR_DEBUG_ASSERT(!m_pJSEngine) { return false; @@ -28,9 +51,32 @@ bool ControllerScriptEngineBase::initialize() { m_bAbortOnWarning = CmdlineArgs::Instance().getControllerAbortOnWarning(); // Create the Script Engine - m_pJSEngine = std::make_shared(this); +#ifdef MIXXX_USE_QML + if (!m_bQmlMode) { +#endif + m_pJSEngine = std::make_shared(this); - m_pJSEngine->installExtensions(QJSEngine::ConsoleExtension); + m_pJSEngine->installExtensions(QJSEngine::ConsoleExtension); +#ifdef MIXXX_USE_QML + } else { + auto pQmlEngine = std::make_shared(this); + pQmlEngine->addImportPath(QStringLiteral(":/mixxx.org/imports")); + if (s_pTrackCollectionManager) { + mixxx::qml::AsyncImageProvider* pImageProvider = new mixxx::qml::AsyncImageProvider( + s_pTrackCollectionManager); + pQmlEngine->addImageProvider(mixxx::qml::AsyncImageProvider::kProviderName, + pImageProvider); + } else { + DEBUG_ASSERT(!"TrackCollectionManager is missing"); + qCWarning(m_logger) << "TrackCollectionManager hasn't been registered yet"; + } + connect(pQmlEngine.get(), + &QQmlEngine::warnings, + this, + &ControllerScriptEngineBase::handleQMLErrors); + m_pJSEngine = std::move(pQmlEngine); + } +#endif QJSValue engineGlobalObject = m_pJSEngine->globalObject(); @@ -126,6 +172,30 @@ void ControllerScriptEngineBase::showScriptExceptionDialog( } } +#ifdef MIXXX_USE_QML +void ControllerScriptEngineBase::showQMLExceptionDialog( + const QQmlError& error, bool bFatalError) { + VERIFY_OR_DEBUG_ASSERT(error.isValid()) { + return; + } + + QString filename = error.url().isLocalFile() ? error.url().toLocalFile() + : error.url().toString(); + + if (filename.isEmpty()) { + filename = QStringLiteral(""); + } + QString errorText = QStringLiteral("Uncaught exception: %1:%2: %3") + .arg(filename, QString::number(error.line()), error.description()); + + qCWarning(m_logger) << "ControllerScriptHandlerBase:" << errorText; + + if (!m_bDisplayingExceptionDialog) { + scriptErrorDialog(errorText, errorText, bFatalError); + } +} +#endif + void ControllerScriptEngineBase::logOrThrowError(const QString& errorMessage) { if (m_bAbortOnWarning) { throwJSError(errorMessage); diff --git a/src/controllers/scripting/controllerscriptenginebase.h b/src/controllers/scripting/controllerscriptenginebase.h index 0919d46eed3a..2129184b641b 100644 --- a/src/controllers/scripting/controllerscriptenginebase.h +++ b/src/controllers/scripting/controllerscriptenginebase.h @@ -2,12 +2,21 @@ #include #include +#include +#include +#include #include #include "util/runtimeloggingcategory.h" +#ifdef MIXXX_USE_QML +#include "controllers/controllerenginethreadcontrol.h" +#endif class Controller; class QJSEngine; +#ifdef MIXXX_USE_QML +class TrackCollectionManager; +#endif /// ControllerScriptEngineBase manages the JavaScript engine for controller scripts. /// ControllerScriptModuleEngine implements the current system using JS modules. @@ -26,6 +35,10 @@ class ControllerScriptEngineBase : public QObject { /// Shows a UI dialog notifying of a script evaluation error. /// Precondition: QJSValue.isError() == true void showScriptExceptionDialog(const QJSValue& evaluationResult, bool bFatal = false); +#ifdef MIXXX_USE_QML + /// Precondition: QML.isValid() == true + void showQMLExceptionDialog(const QQmlError& evaluationResult, bool bFatal = false); +#endif void throwJSError(const QString& message); bool willAbortOnWarning() const { @@ -40,6 +53,10 @@ class ControllerScriptEngineBase : public QObject { return m_bTesting; } +#ifdef MIXXX_USE_QML + static void registerTrackCollectionManager( + std::shared_ptr pTrackCollectionManager); +#endif signals: void beforeShutdown(); @@ -49,7 +66,19 @@ class ControllerScriptEngineBase : public QObject { void scriptErrorDialog(const QString& detailedError, const QString& key, bool bFatal = false); void logOrThrowError(const QString& errorMessage); +#ifdef MIXXX_USE_QML + inline void setQMLMode(bool qmlFlag) { + m_bQmlMode = qmlFlag; + } + inline void setErrorsAreFatal(bool errorsAreFatal) { + m_bErrorsAreFatal = errorsAreFatal; + } +#endif + bool m_bDisplayingExceptionDialog; +#ifdef MIXXX_USE_QML + bool m_bErrorsAreFatal; +#endif std::shared_ptr m_pJSEngine; Controller* m_pController; @@ -57,13 +86,35 @@ class ControllerScriptEngineBase : public QObject { bool m_bAbortOnWarning; +#ifdef MIXXX_USE_QML + bool m_bQmlMode; +#endif bool m_bTesting; +#ifdef MIXXX_USE_QML + private: + static inline std::shared_ptr s_pTrackCollectionManager; + + protected: + /// Pause the GUI main thread. Pause is required by rendering + /// thread (https://doc.qt.io/qt-6/qquickrendercontrol.html#sync). This + /// offscreen render thread to pause the main "GUI thread" for onboard + /// screens + /// The documentation isn't completely clear about this, but after + /// testing, it appears that the "GUI main thread" is the thread where the QML + /// engine leaves in (also the main thread if we were using a + /// QMLApplication, which isn't the case here) + ControllerEngineThreadControl m_engineThreadControl; +#endif + protected slots: void reload(); private slots: void errorDialogButton(const QString& key, QMessageBox::StandardButton button); +#ifdef MIXXX_USE_QML + void handleQMLErrors(const QList& qmlErrors); +#endif friend class ColorMapperJSProxy; friend class MidiControllerTest; diff --git a/src/controllers/scripting/legacy/controllerscriptenginelegacy.cpp b/src/controllers/scripting/legacy/controllerscriptenginelegacy.cpp index 66cb55b4a06e..4dd7fb9f4b9f 100644 --- a/src/controllers/scripting/legacy/controllerscriptenginelegacy.cpp +++ b/src/controllers/scripting/legacy/controllerscriptenginelegacy.cpp @@ -1,12 +1,53 @@ #include "controllers/scripting/legacy/controllerscriptenginelegacy.h" +#ifdef MIXXX_USE_QML +#include +#include +#include +#include +#include +#include + +// Prevent conflict with methods called 'emit' in source +#pragma push_macro("emit") +#undef emit +#include +#pragma pop_macro("emit") +#endif + #include "control/controlobject.h" #include "controllers/controller.h" +#ifdef MIXXX_USE_QML +#include "controllers/rendering/controllerrenderingengine.h" +#endif #include "controllers/scripting/colormapperjsproxy.h" #include "controllers/scripting/legacy/controllerscriptinterfacelegacy.h" #include "errordialoghandler.h" #include "mixer/playermanager.h" #include "moc_controllerscriptenginelegacy.cpp" +#ifdef MIXXX_USE_QML +#include "util/assert.h" +#include "util/cmdlineargs.h" + +using Clock = std::chrono::steady_clock; +#endif + +#ifdef MIXXX_USE_QML +namespace { +const QByteArray kScreenTransformFunctionUntypedSignature = + QMetaObject::normalizedSignature( + "transformFrame(QVariant,QVariant)"); +const QByteArray kScreenTransformFunctionTypedSignature = + QMetaObject::normalizedSignature("transformFrame(QVariant,QDateTime)"); +const QByteArray kScreenInitFunctionUntypedSignature = + QMetaObject::normalizedSignature( + "init(QVariant,QVariant)"); +const QByteArray kScreenInitFunctionTypedSignature = + QMetaObject::normalizedSignature("init(QString,bool)"); +const QByteArray kScreenShutdownFunctionSignature = + QMetaObject::normalizedSignature("shutdown()"); +} // anonymous namespace +#endif ControllerScriptEngineLegacy::ControllerScriptEngineLegacy( Controller* controller, const RuntimeLoggingCategory& logger) @@ -14,13 +55,41 @@ ControllerScriptEngineLegacy::ControllerScriptEngineLegacy( connect(&m_fileWatcher, &QFileSystemWatcher::fileChanged, this, + [this](const QString& changedFile) { + qCDebug(m_logger) << "File" << changedFile << "has been changed."; + // This is to prevent double-reload when a file is updated twice + // in a row as part of the normal saving process. See note in + // QFileSystemWatcher::fileChanged documentation. + if (m_fileWatcher.removePath(changedFile)) { + reload(); + } + }); +#ifdef MIXXX_USE_QML + connect(&m_fileWatcher, + &QFileSystemWatcher::directoryChanged, + this, &ControllerScriptEngineLegacy::reload); +#endif } ControllerScriptEngineLegacy::~ControllerScriptEngineLegacy() { shutdown(); } +void ControllerScriptEngineLegacy::watchFilePath(const QString& path) { + if (m_fileWatcher.files().contains(path) || m_fileWatcher.directories().contains(path)) { + qCDebug(m_logger) << "File" << path << "is already being watch for controller auto-reload"; + return; + } + + if (!m_fileWatcher.addPath(path)) { + qCWarning(m_logger) << "Failed to watch script file" + << path; + } else { + qCDebug(m_logger) << "Watching file" << path << "for controller auto-reload"; + } +} + bool ControllerScriptEngineLegacy::callFunctionOnObjects( const QList& scriptFunctionPrefixes, const QString& function, @@ -57,6 +126,142 @@ bool ControllerScriptEngineLegacy::callFunctionOnObjects( return success; } +bool ControllerScriptEngineLegacy::callShutdownFunction() { + // There is no js engine if the mapping was not loaded from a file but by + // creating a new, empty mapping LegacyMidiControllerMapping with the wizard + if (!m_pJSEngine) { + return true; + } + +#ifdef MIXXX_USE_QML + if (!m_bQmlMode) { +#endif + return callFunctionOnObjects(m_scriptFunctionPrefixes, "shutdown"); +#ifdef MIXXX_USE_QML + } else { + VERIFY_OR_DEBUG_ASSERT(!m_pJSEngine->hasError()) { + qCWarning(m_logger) << "Controller JS engine has an unhandled error."; + qCDebug(m_logger) << "Unhandled controller JS error is:" + << m_pJSEngine->catchError().toString(); + } + QHashIterator> i(m_rootItems); + bool success = true; + while (i.hasNext()) { + i.next(); + const QMetaObject* metaObject = i.value()->metaObject(); + const QString& screenIdentifier = i.key(); + + VERIFY_OR_DEBUG_ASSERT(metaObject) { + qCWarning(m_logger) + << "Invalid meta object for screen" << screenIdentifier + << "It may be that an unhandled issue occurred when importing " + "the scene."; + continue; + } + + QMetaMethod shutdownFunction; + int methodIdx = metaObject->indexOfMethod(kScreenShutdownFunctionSignature); + + if (methodIdx == -1 || !metaObject->method(methodIdx).isValid()) { + qCDebug(m_logger) << "QML Scene for screen" << screenIdentifier + << "has no valid shutdown method."; + continue; + } + + shutdownFunction = metaObject->method(methodIdx); + + qCDebug(m_logger) << "Executing shutdown on QML Scene " << screenIdentifier; + + VERIFY_OR_DEBUG_ASSERT(!m_pJSEngine->hasError()) { + qCWarning(m_logger) << "Controller JS engine has an unhandled error. Discarding."; + qCDebug(m_logger) << "Controller JS error is:" + << m_pJSEngine->catchError().toString(); + } + + success &= shutdownFunction.invoke(i.value().get(), + Qt::DirectConnection); + // Error handling is done in ControllerScriptEngineBase, with the + // connection QQmlEngine::warnings -> + // ControllerScriptEngineBase::handleQMLErrors + } + return success; + } +#endif +} +bool ControllerScriptEngineLegacy::callInitFunction() { + // m_pController is nullptr in tests. + const auto controllerName = m_pController ? m_pController->getName() : QString{}; + +#ifdef MIXXX_USE_QML + if (!m_bQmlMode) { +#endif + const auto args = QJSValueList{ + controllerName, + m_logger().isDebugEnabled(), + }; + return callFunctionOnObjects(m_scriptFunctionPrefixes, "init", args, true); +#ifdef MIXXX_USE_QML + } else { + VERIFY_OR_DEBUG_ASSERT(!m_pJSEngine->hasError()) { + qCWarning(m_logger) << "Controller JS engine has an unhandled error."; + qCDebug(m_logger) << "Unhandled controller JS error is:" + << m_pJSEngine->catchError().toString(); + } + QHashIterator> i(m_rootItems); + bool success = true; + while (i.hasNext()) { + i.next(); + const QMetaObject* metaObject = i.value()->metaObject(); + const QString& screenIdentifier = i.key(); + + VERIFY_OR_DEBUG_ASSERT(metaObject) { + qCWarning(m_logger) + << "Invalid meta object for screen" << screenIdentifier + << "It may be that an unhandled issue occurred when importing " + "the scene."; + continue; + } + + QMetaMethod initFunction; + bool typed = false; + int methodIdx = metaObject->indexOfMethod(kScreenInitFunctionUntypedSignature); + + if (methodIdx == -1 || !metaObject->method(methodIdx).isValid()) { + qCDebug(m_logger) << "QML Scene for screen" << screenIdentifier + << "has no valid untyped init method."; + methodIdx = metaObject->indexOfMethod(kScreenInitFunctionTypedSignature); + typed = true; + } + + initFunction = metaObject->method(methodIdx); + + if (!initFunction.isValid()) { + qCDebug(m_logger) << "QML Scene for screen" << screenIdentifier + << "has no valid typed init method. Skipping."; + continue; + } + + qCDebug(m_logger) << "Executing init on QML Scene " << screenIdentifier; + if (typed) { + success &= initFunction.invoke(i.value().get(), + Qt::DirectConnection, + Q_ARG(QString, controllerName), + Q_ARG(bool, m_logger().isDebugEnabled())); + } else { + success &= initFunction.invoke(i.value().get(), + Qt::DirectConnection, + Q_ARG(QVariant, controllerName), + Q_ARG(QVariant, m_logger().isDebugEnabled())); + } + // Error handling is done in ControllerScriptEngineBase, with the + // connection QQmlEngine::warnings -> + // ControllerScriptEngineBase::handleQMLErrors + } + return success; + } +#endif +} + QJSValue ControllerScriptEngineLegacy::wrapFunctionCode( const QString& codeSnippet, int numberOfArgs) { // This function is called from outside the controller engine, so we can't @@ -90,6 +295,25 @@ QJSValue ControllerScriptEngineLegacy::wrapFunctionCode( return wrappedFunction; } +#ifdef MIXXX_USE_QML +void ControllerScriptEngineLegacy::setModulePaths( + const QList& modules) { + const QStringList paths = m_fileWatcher.files(); + if (!paths.isEmpty()) { + m_fileWatcher.removePaths(paths); + } + + m_modules = modules; +} +void ControllerScriptEngineLegacy::setInfoScreens( + const QList& screens) { + m_rootItems.clear(); + m_renderingScreens.clear(); + m_transformScreenFrameFunctions.clear(); + m_infoScreens = screens; +} +#endif + void ControllerScriptEngineLegacy::setScriptFiles( const QList& scripts) { const QStringList paths = m_fileWatcher.files(); @@ -97,6 +321,16 @@ void ControllerScriptEngineLegacy::setScriptFiles( m_fileWatcher.removePaths(paths); } m_scriptFiles = scripts; + +#ifdef MIXXX_USE_QML + setQMLMode(std::any_of(std::execution::par_unseq, + m_scriptFiles.cbegin(), + m_scriptFiles.cend(), + [](const auto& scriptFileInfo) { + return scriptFileInfo.type == + LegacyControllerMapping::ScriptFileInfo::Type::Qml; + })); +#endif } void ControllerScriptEngineLegacy::setSettings( @@ -116,6 +350,57 @@ bool ControllerScriptEngineLegacy::initialize() { return false; } +#ifdef MIXXX_USE_QML + // During the initialisation, any QML errors are considered fatal. + setErrorsAreFatal(true); + QMap> availableScreens; + + if (m_bQmlMode) { + for (const LegacyControllerMapping::ScreenInfo& screen : std::as_const(m_infoScreens)) { + VERIFY_OR_DEBUG_ASSERT(!availableScreens.contains(screen.identifier)) { + qCWarning(m_logger) << "A controller screen already contains the " + "identifier " + << screen.identifier; + return false; + } + availableScreens.insert(screen.identifier, + std::make_shared(screen, &m_engineThreadControl)); + + if (!availableScreens.value(screen.identifier)->isValid()) { + qCWarning(m_logger) << "Unable to start the screen render for" << screen.identifier; + return false; + } + + // For testing, do not actually initialize the rendering engine, just check for + // compatibility above. + if (m_bTesting) { + continue; + } + + // Rename the ControllerRenderingEngine with the actual screen + // identifier to help debugging. + availableScreens.value(screen.identifier) + ->thread() + ->setObjectName( + QString("CtrlScreen_%1").arg(screen.identifier)); + availableScreens.value(screen.identifier) + ->requestEngineSetup( + std::dynamic_pointer_cast(m_pJSEngine)); + + if (!availableScreens.value(screen.identifier)->isValid()) { + qCWarning(m_logger) << QString( + "Unable to setup the screen render for %1.") + .arg(screen.identifier); + availableScreens.value(screen.identifier)->stop(); + return false; + } + } + } else if (!m_infoScreens.isEmpty()) { + qCWarning(m_logger) << "Controller mapping has screen definitions but no QML " + "files to render on it. Ignoring."; + } +#endif + // Binary data is passed from the Controller as a QByteArray, which // QJSEngine::toScriptValue converts to an ArrayBuffer in JavaScript. // ArrayBuffer cannot be accessed with the [] operator in JS; it needs @@ -139,15 +424,90 @@ bool ControllerScriptEngineLegacy::initialize() { engineGlobalObject.setProperty( "engine", m_pJSEngine->newQObject(legacyScriptInterface)); +#ifdef MIXXX_USE_QML + if (m_bQmlMode) { + for (const LegacyControllerMapping::QMLModuleInfo& module : + std::as_const(m_modules)) { + auto path = module.dirinfo.absoluteFilePath(); + QDirIterator it(path, + {"*.qml"}, + QDir::Files, + QDirIterator::Subdirectories); + while (it.hasNext()) { + watchFilePath(it.next()); + } + watchFilePath(path); + auto pQmlEngine = std::dynamic_pointer_cast(m_pJSEngine); + pQmlEngine->addImportPath(path); + qCWarning(m_logger) << pQmlEngine->importPathList(); + } + } else if (!m_modules.isEmpty()) { + qCWarning(m_logger) << "Controller mapping has QML library definitions but no " + "QML files to use it. Ignoring."; + } + + // If we encounter a failure while loading a scene, we will need to properly + // stop the screen threads before shutting down. + bool sceneBindingHasFailure = false; +#endif for (const LegacyControllerMapping::ScriptFileInfo& script : std::as_const(m_scriptFiles)) { - if (!evaluateScriptFile(script.file)) { - shutdown(); - return false; +#ifdef MIXXX_USE_QML + if (script.type == LegacyControllerMapping::ScriptFileInfo::Type::Javascript) { +#endif + if (!evaluateScriptFile(script.file)) { + shutdown(); + return false; + } + if (!script.identifier.isEmpty()) { + m_scriptFunctionPrefixes.append(script.identifier); + } +#ifdef MIXXX_USE_QML + } else { + if (script.identifier.isEmpty()) { + while (!availableScreens.isEmpty()) { + QString screenIdentifier(availableScreens.firstKey()); + if (!bindSceneToScreen(script, + screenIdentifier, + availableScreens.take(screenIdentifier))) { + sceneBindingHasFailure = true; + } + } + } else { + if (!availableScreens.contains(script.identifier)) { + qCCritical(m_logger) << "Not screen" << script.identifier << "found!"; + + sceneBindingHasFailure = true; + break; + } + if (!bindSceneToScreen(script, + script.identifier, + availableScreens.take(script.identifier))) { + sceneBindingHasFailure = true; + } + } } - if (!script.functionPrefix.isEmpty()) { - m_scriptFunctionPrefixes.append(script.functionPrefix); + } + + if (!availableScreens.isEmpty()) { + if (!sceneBindingHasFailure) { + qCWarning(m_logger) + << "Found screen with no QML scene able to run on it. Ignoring" + << availableScreens.size() << "screens"; + } + + while (!availableScreens.isEmpty()) { + auto pScreen = availableScreens.take(availableScreens.firstKey()); + VERIFY_OR_DEBUG_ASSERT(!pScreen->isValid() || + !pScreen->isRunning() || pScreen->stop()) { + qCWarning(m_logger) << "Unable to stop the screen"; + }; } } + if (sceneBindingHasFailure) { + shutdown(); + return false; +#endif + } // For testing, do not actually initialize the scripts, just check for // syntax errors above. @@ -165,26 +525,257 @@ bool ControllerScriptEngineLegacy::initialize() { wrapFunctionCode(functionName, 2))); } - // m_pController is nullptr in tests. - const auto controllerName = m_pController ? m_pController->getName() : QString{}; - const auto args = QJSValueList{ - controllerName, - m_logger().isDebugEnabled(), - }; - if (!callFunctionOnObjects(m_scriptFunctionPrefixes, "init", args, true)) { +#ifdef MIXXX_USE_QML + m_engineThreadControl.setCanPause(true); + for (const auto& pScreen : std::as_const(m_renderingScreens)) { + pScreen->start(); + } +#endif + + if (!callInitFunction()) { shutdown(); return false; } +#ifdef MIXXX_USE_QML + // At runtime, QML errors aren't considered fatal anymore now that the engine has started. + setErrorsAreFatal(false); +#endif + + return true; +} + +#ifdef MIXXX_USE_QML +void ControllerScriptEngineLegacy::extractTransformFunction( + const QMetaObject* metaObject, const QString& screenIdentifier) { + VERIFY_OR_DEBUG_ASSERT(metaObject) { + qCWarning(m_logger) + << "Invalid meta object for screen" << screenIdentifier + << "It may be that an unhandled issue occurred when importing " + "the scene."; + return; + } + + QMetaMethod transformFunction; + bool typed = false; + int methodIdx = metaObject->indexOfMethod(kScreenTransformFunctionUntypedSignature); + + if (methodIdx == -1 || !metaObject->method(methodIdx).isValid()) { + qCDebug(m_logger) << "QML Scene for screen" << screenIdentifier + << "has no valid untyped transformFrame method."; + methodIdx = metaObject->indexOfMethod(kScreenTransformFunctionTypedSignature); + typed = true; + } + + transformFunction = metaObject->method(methodIdx); + + if (!transformFunction.isValid()) { + qCDebug(m_logger) << "QML Scene for screen" << screenIdentifier + << "has no valid typed transformFrame method. The " + "frame data will be sent " + "untransformed"; + QStringList methods; + for (int i = metaObject->methodOffset(); i < metaObject->methodCount(); ++i) { + methods << QString::fromLatin1(metaObject->method(i).methodSignature()); + } + qCDebug(m_logger) << "Found methods are: " << methods.join(", "); + } + + m_transformScreenFrameFunctions.insert(screenIdentifier, + TransformScreenFrameFunction{transformFunction, typed}); +} + +bool ControllerScriptEngineLegacy::bindSceneToScreen( + const LegacyControllerMapping::ScriptFileInfo& qmlFile, + const QString& screenIdentifier, + std::shared_ptr pScreen) { + // Like for Javascript, if the script is invalid, it should be watched so the user can fix it + // without having to restart Mixxx. So, add it to the watcher before + // evaluating it. + watchFilePath(qmlFile.file.absoluteFilePath()); + + auto pScene = loadQMLFile(qmlFile, pScreen); + if (!pScene) { + VERIFY_OR_DEBUG_ASSERT(!pScreen->isValid() || + !pScreen->isRunning() || pScreen->stop()) { + qCWarning(m_logger) << "Unable to stop the screen"; + }; + return false; + } + const QMetaObject* metaObject = pScene->metaObject(); + + extractTransformFunction(metaObject, screenIdentifier); + connect(pScreen.get(), + &ControllerRenderingEngine::frameRendered, + this, + &ControllerScriptEngineLegacy::handleScreenFrame); + m_renderingScreens.insert(screenIdentifier, pScreen); + m_rootItems.insert(screenIdentifier, pScene); + // In case a rendering issue occurs, we need to shutdown the controller + // since its only purpose is to render screens. This might not be the case + // in the future controller modules + connect(pScreen.get(), + &ControllerRenderingEngine::stopping, + this, + &ControllerScriptEngineLegacy::shutdown); return true; } +void ControllerScriptEngineLegacy::handleScreenFrame( + const LegacyControllerMapping::ScreenInfo& screenInfo, + const QImage& frame, + const QDateTime& timestamp) { + VERIFY_OR_DEBUG_ASSERT( + m_transformScreenFrameFunctions.contains(screenInfo.identifier) || + m_renderingScreens.contains(screenInfo.identifier)) { + qCWarning(m_logger) << "Unable to find transform function info for the given screen"; + return; + }; + VERIFY_OR_DEBUG_ASSERT(m_rootItems.contains(screenInfo.identifier)) { + qCWarning(m_logger) << "Unable to find a root item for the given screen"; + return; + }; + + if (CmdlineArgs::Instance().getControllerPreviewScreens()) { + QImage screenDebug(frame); + + switch (screenInfo.endian) { + case LegacyControllerMapping::ScreenInfo::ColorEndian::Big: + qFromBigEndian(frame.constBits(), + frame.sizeInBytes() / 2, + screenDebug.bits()); + break; + case LegacyControllerMapping::ScreenInfo::ColorEndian::Little: + qFromLittleEndian(frame.constBits(), + frame.sizeInBytes() / 2, + screenDebug.bits()); + break; + default: + break; + } + if (screenInfo.reversedColor) { + screenDebug.rgbSwap(); + } + + emit previewRenderedScreen(screenInfo, screenDebug); + } + + QByteArray input(std::bit_cast(frame.constBits()), frame.sizeInBytes()); + const TransformScreenFrameFunction& transformMethod = + m_transformScreenFrameFunctions[screenInfo.identifier]; + + if (!transformMethod.method.isValid() && screenInfo.rawData) { + m_renderingScreens[screenInfo.identifier]->requestSendingFrameData(m_pController, input); + return; + } + + if (!transformMethod.method.isValid()) { + qCWarning(m_logger) + << "Could not find a valid transform function but the screen " + "doesn't accept raw data. Aborting screen rendering."; + m_renderingScreens[screenInfo.identifier]->stop(); + return; + } + + QVariant returnedValue; + + VERIFY_OR_DEBUG_ASSERT(!m_pJSEngine->hasError()) { + qCWarning(m_logger) << "Controller JS engine has an unhandled error. Discarding."; + qCDebug(m_logger) << "Controller JS error is:" << m_pJSEngine->catchError().toString(); + } + // During the frame transformation, any QML errors are considered fatal. + setErrorsAreFatal(true); + bool isSuccessful = transformMethod.typed + ? transformMethod.method.invoke( + m_rootItems.value(screenInfo.identifier).get(), + Qt::DirectConnection, + Q_RETURN_ARG(QVariant, returnedValue), + Q_ARG(QVariant, input), + Q_ARG(QDateTime, timestamp)) + : transformMethod.method.invoke( + m_rootItems.value(screenInfo.identifier).get(), + Qt::DirectConnection, + Q_RETURN_ARG(QVariant, returnedValue), + Q_ARG(QVariant, input), + Q_ARG(QVariant, timestamp)); + setErrorsAreFatal(false); + + if (!isSuccessful) { + qCWarning(m_logger) << "Could not transform rendering buffer for screen" + << screenInfo.identifier; + + // We manually stop the screen before we trigger the shutdown procedure + // as this last one may continue rendering process in order to perform + // screen splash off. + shutdown(); + return; + } + if (!isSuccessful || !returnedValue.isValid()) { + qCWarning(m_logger) << "Could not transform rendering buffer. The transform " + "function didn't return the expected Array. Stopping " + "rendering on this screen"; + return; + } + + QByteArray transformedFrame; + + if (returnedValue.canView()) { + transformedFrame = returnedValue.view(); + } else if (returnedValue.canConvert()) { + transformedFrame = returnedValue.toByteArray(); + } else { + qCWarning(m_logger) << "Unable to interpret the returned data " << returnedValue; + return; + } + + if (CmdlineArgs::Instance().getControllerDebug()) { + qCDebug(m_logger) << "Transform screen data for screen " << screenInfo.identifier + << "(first 64 bytes)" + << QByteArray(transformedFrame.toHex(' '), 128); + m_pController->sendBytes(returnedValue.view()); + } + + m_renderingScreens[screenInfo.identifier]->requestSendingFrameData( + m_pController, transformedFrame); +} +#endif + void ControllerScriptEngineLegacy::shutdown() { - // There is no js engine if the mapping was not loaded from a file but by - // creating a new, empty mapping LegacyMidiControllerMapping with the wizard - if (m_pJSEngine) { - callFunctionOnObjects(m_scriptFunctionPrefixes, "shutdown"); + callShutdownFunction(); + +#ifdef MIXXX_USE_QML + m_engineThreadControl.setCanPause(false); + // Wait till the splash off animation has finished rendering. + std::chrono::milliseconds maxSplashOffDuration{}; + for (const auto& pScreen : std::as_const(m_renderingScreens)) { + if (!pScreen->isRunning()) { + continue; + } + maxSplashOffDuration = std::max(maxSplashOffDuration, pScreen->info().splash_off); + } + + auto splashOffDeadline = Clock::now() + maxSplashOffDuration; + while (splashOffDeadline > Clock::now()) { + QCoreApplication::processEvents(QEventLoop::WaitForMoreEvents, + std::chrono::duration_cast( + splashOffDeadline - Clock::now()) + .count()); + } + + m_rootItems.clear(); + for (const auto& pScreen : std::as_const(m_renderingScreens)) { + // When stopping, the rendering engine emits an event which triggers the + // shutdown in case it was initiated following a rendering issue. We + // need to disconnect first before stopping. + pScreen->disconnect(this); + VERIFY_OR_DEBUG_ASSERT(!pScreen->isValid() || + !pScreen->isRunning() || pScreen->stop()) { + qCWarning(m_logger) << "Unable to stop the screen"; + }; } + m_renderingScreens.clear(); + m_transformScreenFrameFunctions.clear(); +#endif m_scriptWrappedFunctionCache.clear(); m_incomingDataFunctions.clear(); m_scriptFunctionPrefixes.clear(); @@ -226,10 +817,7 @@ bool ControllerScriptEngineLegacy::evaluateScriptFile(const QFileInfo& scriptFil // If the script is invalid, it should be watched so the user can fix it // without having to restart Mixxx. So, add it to the watcher before // evaluating it. - if (!m_fileWatcher.addPath(scriptFile.absoluteFilePath())) { - qCWarning(m_logger) << "Failed to watch script file" << scriptFile.absoluteFilePath(); - }; - + watchFilePath(scriptFile.absoluteFilePath()); qCDebug(m_logger) << "Loading" << scriptFile.absoluteFilePath(); @@ -278,6 +866,90 @@ bool ControllerScriptEngineLegacy::evaluateScriptFile(const QFileInfo& scriptFil return true; } +#ifdef MIXXX_USE_QML +std::shared_ptr ControllerScriptEngineLegacy::loadQMLFile( + const LegacyControllerMapping::ScriptFileInfo& qmlScript, + std::shared_ptr pScreen) { + VERIFY_OR_DEBUG_ASSERT(m_pJSEngine || + qmlScript.type != + LegacyControllerMapping::ScriptFileInfo::Type::Qml) { + return nullptr; + } + + QQmlComponent qmlComponent = QQmlComponent( + std::dynamic_pointer_cast(m_pJSEngine).get()); + + QFile scene = QFile(qmlScript.file.absoluteFilePath()); + if (!scene.exists()) { + qCWarning(m_logger) << "Unable to load the QML scene:" << qmlScript.file.absoluteFilePath() + << "does not exist."; + return nullptr; + } + + QDir dir(m_resourcePath + "/qml/"); + + scene.open(QIODevice::ReadOnly); + qmlComponent.setData(scene.readAll(), + // Obfuscate the scene filename to make it appear in the QML folder. + // This allows a smooth integration with QML components. + QUrl::fromLocalFile( + dir.absoluteFilePath(qmlScript.file.fileName()))); + scene.close(); + + while (qmlComponent.isLoading()) { + qCDebug(m_logger) << "Waiting for component " + << qmlScript.file.absoluteFilePath() + << " to be ready: " << qmlComponent.progress(); + QCoreApplication::processEvents(QEventLoop::WaitForMoreEvents, 500); + } + + if (qmlComponent.isError()) { + const QList errorList = qmlComponent.errors(); + for (const QQmlError& error : errorList) { + qCWarning(m_logger) << "Unable to load the QML scene:" << error.url() + << "at line" << error.line() << ", error: " << error; + showQMLExceptionDialog(error, true); + } + return nullptr; + } + + VERIFY_OR_DEBUG_ASSERT(qmlComponent.isReady()) { + qCWarning(m_logger) << "QMLComponent isn't ready although synchronous load was requested."; + return nullptr; + } + + QObject* pRootObject = qmlComponent.createWithInitialProperties( + QVariantMap{{"screenId", pScreen->info().identifier}}); + if (qmlComponent.isError()) { + const QList errorList = qmlComponent.errors(); + for (const QQmlError& error : errorList) { + qCWarning(m_logger) << error.url() << error.line() << error; + } + return nullptr; + } + + std::shared_ptr rootItem = + std::shared_ptr(qobject_cast(pRootObject)); + if (!rootItem) { + qWarning("run: Not a QQuickItem"); + delete pRootObject; + return nullptr; + } + + watchFilePath(qmlScript.file.absoluteFilePath()); + + // The root item is ready. Associate it with the window. + if (!m_bTesting) { + rootItem->setParentItem(pScreen->quickWindow()->contentItem()); + + rootItem->setWidth(pScreen->quickWindow()->width()); + rootItem->setHeight(pScreen->quickWindow()->height()); + } + + return rootItem; +} +#endif + QJSValue ControllerScriptEngineLegacy::wrapArrayBufferCallback(const QJSValue& callback) { return m_makeArrayBufferWrapperFunction.call(QJSValueList{callback}); } diff --git a/src/controllers/scripting/legacy/controllerscriptenginelegacy.h b/src/controllers/scripting/legacy/controllerscriptenginelegacy.h index 2491ff78a52b..b0605f4b584e 100644 --- a/src/controllers/scripting/legacy/controllerscriptenginelegacy.h +++ b/src/controllers/scripting/legacy/controllerscriptenginelegacy.h @@ -4,10 +4,18 @@ #include #include #include +#ifdef MIXXX_USE_QML +#include +#endif #include "controllers/legacycontrollermapping.h" #include "controllers/scripting/controllerscriptenginebase.h" +#ifdef MIXXX_USE_QML +class QQuickItem; +class ControllerRenderingEngine; +#endif + /// ControllerScriptEngineLegacy loads and executes controller scripts for the legacy /// JS/XML hybrid controller mapping system. class ControllerScriptEngineLegacy : public ControllerScriptEngineBase { @@ -42,6 +50,26 @@ class ControllerScriptEngineLegacy : public ControllerScriptEngineBase { void setSettings( const QList>& settings); +#ifdef MIXXX_USE_QML + void setModulePaths(const QList& scripts); + void setInfoScreens(const QList& scripts); + void setResourcePath(const QString& resourcePath) { + m_resourcePath = resourcePath; + } + + private slots: + void handleScreenFrame( + const LegacyControllerMapping::ScreenInfo& screeninfo, + const QImage& frame, + const QDateTime& timestamp); + + signals: + /// Emitted when a screen has been rendered. + // TODO (XXX) Move this signal in ControllerScriptEngineBase when ScreenInfo + // isn't tight to LegacyControllerMapping anymore. + void previewRenderedScreen(const LegacyControllerMapping::ScreenInfo& screen, QImage frame); +#endif + private: struct Setting { QString name; @@ -49,16 +77,50 @@ class ControllerScriptEngineLegacy : public ControllerScriptEngineBase { }; bool evaluateScriptFile(const QFileInfo& scriptFile); +#ifdef MIXXX_USE_QML + bool bindSceneToScreen( + const LegacyControllerMapping::ScriptFileInfo& qmlFile, + const QString& screenIdentifier, + std::shared_ptr pScreen); + void extractTransformFunction(const QMetaObject* metaObject, const QString& screenIdentifier); + + std::shared_ptr loadQMLFile( + const LegacyControllerMapping::ScriptFileInfo& qmlScript, + std::shared_ptr pScreen); + + struct TransformScreenFrameFunction { + QMetaMethod method; + bool typed; + }; +#endif + + /// @brief Call the shutdown hook on the mapping script. + /// @return true if the hook was run successfully, or if there was none. + bool callShutdownFunction(); + /// @brief Call the init hook on the mapping script. + /// @return true if the hook was run successfully, or if there was none. + bool callInitFunction(); void shutdown() override; - QJSValue wrapArrayBufferCallback(const QJSValue& callback); bool callFunctionOnObjects(const QList& scriptFunctionPrefixes, const QString&, const QJSValueList& args = {}, bool bFatalError = false); + void watchFilePath(const QString& path); QJSValue m_makeArrayBufferWrapperFunction; QList m_scriptFunctionPrefixes; +#ifdef MIXXX_USE_QML + QHash> m_renderingScreens; + // Contains all the scenes loaded for this mapping. Key is the scene + // identifier (LegacyControllerMapping::ScreenInfo::identifier), value in + // the QML root item + QHash> m_rootItems; + QHash m_transformScreenFrameFunctions; + QList m_modules; + QList m_infoScreens; + QString m_resourcePath{QStringLiteral(".")}; +#endif QList m_incomingDataFunctions; QHash m_scriptWrappedFunctionCache; QList m_scriptFiles; diff --git a/src/coreservices.cpp b/src/coreservices.cpp index 82af9c68f6e6..d1f807d1c9d5 100644 --- a/src/coreservices.cpp +++ b/src/coreservices.cpp @@ -28,6 +28,20 @@ #include "preferences/dialog/dlgprefmodplug.h" #endif #include "skin/skincontrols.h" +#ifdef MIXXX_USE_QML +#include +#include + +#include "controllers/scripting/controllerscriptenginebase.h" +#include "qml/qmlconfigproxy.h" +#include "qml/qmlcontrolproxy.h" +#include "qml/qmldlgpreferencesproxy.h" +#include "qml/qmleffectslotproxy.h" +#include "qml/qmleffectsmanagerproxy.h" +#include "qml/qmllibraryproxy.h" +#include "qml/qmlplayermanagerproxy.h" +#include "qml/qmlplayerproxy.h" +#endif #include "soundio/soundmanager.h" #include "sources/soundsourceproxy.h" #include "util/clipboard.h" @@ -448,6 +462,33 @@ void CoreServices::initialize(QApplication* pApp) { } m_isInitialized = true; + +#ifdef MIXXX_USE_QML + initializeQMLSingletons(); +} + +void CoreServices::initializeQMLSingletons() { + // Any uncreateable non-singleton types registered here require + // arguments that we don't want to expose to QML directly. Instead, they + // can be retrieved by member properties or methods from the singleton + // types. + // + // The alternative would be to register their *arguments* in the QML + // system, which would improve nothing, or we had to expose them as + // singletons to that they can be accessed by components instantiated by + // QML, which would also be suboptimal. + mixxx::qml::QmlEffectsManagerProxy::registerEffectsManager(getEffectsManager()); + mixxx::qml::QmlPlayerManagerProxy::registerPlayerManager(getPlayerManager()); + mixxx::qml::QmlConfigProxy::registerUserSettings(getSettings()); + mixxx::qml::QmlLibraryProxy::registerLibrary(getLibrary()); + + ControllerScriptEngineBase::registerTrackCollectionManager(getTrackCollectionManager()); + + // Currently, it is required to enforce QQuickWindow RHI backend to use + // OpenGL on all platforms to allow offscreen rendering to function as + // expected + QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); +#endif } void CoreServices::initializeKeyboard() { @@ -555,6 +596,16 @@ void CoreServices::finalize() { Timer t("CoreServices::~CoreServices"); t.start(); +#ifdef MIXXX_USE_QML + // Delete all the QML singletons in order to prevent controller leaks + mixxx::qml::QmlEffectsManagerProxy::registerEffectsManager(nullptr); + mixxx::qml::QmlPlayerManagerProxy::registerPlayerManager(nullptr); + mixxx::qml::QmlConfigProxy::registerUserSettings(nullptr); + mixxx::qml::QmlLibraryProxy::registerLibrary(nullptr); + + ControllerScriptEngineBase::registerTrackCollectionManager(nullptr); +#endif + // Stop all pending library operations qDebug() << t.elapsed(false).debugMillisWithUnit() << "stopping pending Library tasks"; m_pTrackCollectionManager->stopLibraryScan(); diff --git a/src/coreservices.h b/src/coreservices.h index b5d5325c4d31..d0c1a2499378 100644 --- a/src/coreservices.h +++ b/src/coreservices.h @@ -115,6 +115,9 @@ class CoreServices : public QObject { void initializeSettings(); void initializeScreensaverManager(); void initializeLogging(); +#ifdef MIXXX_USE_QML + void initializeQMLSingletons(); +#endif /// Tear down CoreServices that were previously initialized by `initialize()`. void finalize(); diff --git a/src/qml/qmlapplication.cpp b/src/qml/qmlapplication.cpp index 11ace8aa0554..dbae62d83dea 100644 --- a/src/qml/qmlapplication.cpp +++ b/src/qml/qmlapplication.cpp @@ -65,20 +65,6 @@ QmlApplication::QmlApplication( // Since DlgPreferences is only meant to be used in the main QML engine, it // follows a strict singleton pattern design QmlDlgPreferencesProxy::s_pInstance = new QmlDlgPreferencesProxy(pDlgPreferences, this); - - // Any uncreateable non-singleton types registered here require arguments - // that we don't want to expose to QML directly. Instead, they can be - // retrieved by member properties or methods from the singleton types. - // - // The alternative would be to register their *arguments* in the QML - // system, which would improve nothing, or we had to expose them as - // singletons to that they can be accessed by components instantiated by - // QML, which would also be suboptimal. - QmlEffectsManagerProxy::registerEffectsManager(pCoreServices->getEffectsManager()); - QmlPlayerManagerProxy::registerPlayerManager(pCoreServices->getPlayerManager()); - QmlConfigProxy::registerUserSettings(pCoreServices->getSettings()); - QmlLibraryProxy::registerLibrary(pCoreServices->getLibrary()); - loadQml(m_mainFilePath); pCoreServices->getControllerManager()->setUpDevices(); @@ -93,10 +79,6 @@ QmlApplication::QmlApplication( QmlApplication::~QmlApplication() { // Delete all the QML singletons in order to prevent leak detection in CoreService - QmlEffectsManagerProxy::registerEffectsManager(nullptr); - QmlPlayerManagerProxy::registerPlayerManager(nullptr); - QmlConfigProxy::registerUserSettings(nullptr); - QmlLibraryProxy::registerLibrary(nullptr); QmlDlgPreferencesProxy::s_pInstance->deleteLater(); } diff --git a/src/test/controller_mapping_file_handler_test.cpp b/src/test/controller_mapping_file_handler_test.cpp new file mode 100644 index 000000000000..57dd8594f347 --- /dev/null +++ b/src/test/controller_mapping_file_handler_test.cpp @@ -0,0 +1,1003 @@ +#include +#include + +#include +#include + +#include "controllers/defs_controllers.h" +#include "controllers/legacycontrollermapping.h" +#include "controllers/legacycontrollermappingfilehandler.h" +#include "helpers/log_test.h" +#include "test/mixxxtest.h" +#include "util/time.h" + +using ::testing::_; +using ::testing::FieldsAre; + +class LegacyControllerMappingFileHandlerTest + : public LegacyControllerMappingFileHandler, + public MixxxTest { + public: + void SetUp() override { + mixxx::Time::setTestMode(true); + mixxx::Time::setTestElapsedTime(mixxx::Duration::fromMillis(10)); + SETUP_LOG_CAPTURE(); + } + + void TearDown() override { + mixxx::Time::setTestMode(false); + } + std::shared_ptr load(const QDomElement&, + const QString&, + const QDir&) override { + throw std::runtime_error("not implemented"); + } + static QFileInfo findLibraryPath(std::shared_ptr, + const QString& dirname, + const QDir&) { + return QFileInfo(QDir("/dummy/path/").absoluteFilePath(dirname)); + } + static QFileInfo findScriptFile(std::shared_ptr, + const QString& filename, + const QDir&) { + return QFileInfo(QDir("/dummy/path/").absoluteFilePath(filename)); + } +}; + +class MockLegacyControllerMapping : public LegacyControllerMapping { + public: + MOCK_METHOD(void, + addScriptFile, + (LegacyControllerMapping::ScriptFileInfo info), + (override)); + MOCK_METHOD(void, + addScreenInfo, + (LegacyControllerMapping::ScreenInfo info), + (override)); + MOCK_METHOD(void, addModule, (const QFileInfo& dirinfo, bool builtin), (override)); + + std::shared_ptr clone() const override { + throw std::runtime_error("not implemented"); + } + bool saveMapping(const QString&) const override { + throw std::runtime_error("not implemented"); + } + bool isMappable() const override { + throw std::runtime_error("not implemented"); + } +}; + +TEST_F(LegacyControllerMappingFileHandlerTest, canParseSimpleMapping) { + QDomDocument doc; + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + auto mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("DummyDeviceDefaultScreen.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + false))); + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_CALL(*mapping, addModule(_, _)).Times(0); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); +} + +TEST_F(LegacyControllerMappingFileHandlerTest, canParseScreenMapping) { + QDomDocument doc; + doc.setContent(QByteArray(R"EOF( + + + + + + + + + + + + )EOF")); + + auto mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("DummyDeviceDefaultScreen.qml"), + QString(""), + QFileInfo("/dummy/path/DummyDeviceDefaultScreen.qml"), + LegacyControllerMapping::ScriptFileInfo::Type::Qml, + false))); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre(QString("main"), + QSize(480, 360), + 20, + 1, + std::chrono::milliseconds(2000), + QImage::Format_RGBA8888, + LegacyControllerMapping::ScreenInfo::ColorEndian::Little, + false, + false))); + EXPECT_CALL(*mapping, addModule(QFileInfo("/dummy/path/foobar"), false)); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); +} + +TEST_F(LegacyControllerMappingFileHandlerTest, screenMappingTargetFPS) { + QDomDocument doc; + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + auto mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(FieldsAre(_, _, 20, _, _, _, _, _, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG(QtWarningMsg, + QString("Invalid target FPS. Target FPS must be between 1 and %0") + .arg(kMaxTargetFps)); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_LOG_MSG(QtWarningMsg, + QString("Unable to parse the field \"targetFps\" as an unsigned " + "integer in the screen definition.")); + + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG( + QtWarningMsg, + QString("Invalid target FPS. Target FPS must be between 1 and %0") + .arg(kMaxTargetFps)); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG( + QtWarningMsg, + QString("Invalid target FPS. Target FPS must be between 1 and %0") + .arg(kMaxTargetFps)); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_LOG_MSG(QtWarningMsg, + QString("Unable to parse the field \"targetFps\" as an unsigned " + "integer in the screen definition.")); + + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG( + QtWarningMsg, + QString("Invalid target FPS. Target FPS must be between 1 and %0") + .arg(kMaxTargetFps)); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); +} + +TEST_F(LegacyControllerMappingFileHandlerTest, screenMappingSize) { + QDomDocument doc; + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + auto mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(FieldsAre(_, QSize(10, 10), _, _, _, _, _, _, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG( + QtWarningMsg, + "Invalid screen size. Screen size must have a width and height above 1 pixel"); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_LOG_MSG(QtWarningMsg, + QString("Unable to parse the field \"height\" as an unsigned " + "integer in the screen definition.")); + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG( + QtWarningMsg, + "Invalid screen size. Screen size must have a width and height above 1 pixel"); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_LOG_MSG(QtWarningMsg, + QString("Unable to parse the field \"height\" as an unsigned " + "integer in the screen definition.")); + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG( + QtWarningMsg, + "Invalid screen size. Screen size must have a width and height above 1 pixel"); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG( + QtWarningMsg, + "Invalid screen size. Screen size must have a width and height above 1 pixel"); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); +} + +TEST_F(LegacyControllerMappingFileHandlerTest, screenMappingBitFormatDefinition) { + // pixelType + // endian + + // No pixel type default to RGB 8-bits depth + QDomDocument doc; + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + auto mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(FieldsAre(_, _, _, _, _, QImage::Format_RGB888, _, _, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(FieldsAre(_, _, _, _, _, QImage::Format_RGB16, _, _, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG( + QtWarningMsg, + "Unsupported pixel format \"FOOBAR\""); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre(_, + _, + _, + _, + _, + _, + LegacyControllerMapping::ScreenInfo::ColorEndian::Little, + _, + _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre(_, + _, + _, + _, + _, + _, + LegacyControllerMapping::ScreenInfo::ColorEndian::Little, + _, + _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre(_, + _, + _, + _, + _, + _, + LegacyControllerMapping::ScreenInfo::ColorEndian::Big, + _, + _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(_)).Times(0); + EXPECT_LOG_MSG( + QtWarningMsg, + "Unknown endian format \"enormous\""); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); +} + +TEST_F(LegacyControllerMappingFileHandlerTest, screenMappingExtraBoolPropertiesDefinition) { + bool kExpectedWarning[] = {false, false, true, true, true, true, true}; + QStringList kFalseValue = {"false", "FALse", "no", "yes", "1", "nope", "maybe"}; + QStringList kTrueValue = {"true", "trUe", "TRUE "}; + QDomDocument doc; + std::shared_ptr mapping; + + // reversed + bool* expectedWarning = &kExpectedWarning[0]; + for (const QString& falseValue : std::as_const(kFalseValue)) { + doc.setContent( + QString(R"EOF( + + + + + + )EOF") + .arg(falseValue) + .toUtf8()); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + if (expectedWarning++) { + EXPECT_LOG_MSG(QtWarningMsg, + QString("Unable to parse the field \"reversed\" as a " + "boolean in the screen definition.")); + } + EXPECT_CALL(*mapping, addScreenInfo(FieldsAre(_, _, _, _, _, _, _, false, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + } + for (const QString& falseValue : std::as_const(kTrueValue)) { + doc.setContent( + QString(R"EOF( + + + + + + )EOF") + .arg(falseValue) + .toUtf8()); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(FieldsAre(_, _, _, _, _, _, _, true, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + } + // raw + expectedWarning = &kExpectedWarning[0]; + for (const QString& falseValue : std::as_const(kFalseValue)) { + doc.setContent( + QString(R"EOF( + + + + + + )EOF") + .arg(falseValue) + .toUtf8()); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(FieldsAre(_, _, _, _, _, _, _, _, false))); + if (expectedWarning++) { + EXPECT_LOG_MSG(QtWarningMsg, + QString("Unable to parse the field \"raw\" as a boolean in " + "the screen definition.")); + } + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + } + for (const QString& falseValue : std::as_const(kTrueValue)) { + doc.setContent( + QString(R"EOF( + + + + + + )EOF") + .arg(falseValue) + .toUtf8()); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, addScreenInfo(FieldsAre(_, _, _, _, _, _, _, _, true))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + } +} + +TEST_F(LegacyControllerMappingFileHandlerTest, screenMappingExtraIntPropertiesDefinition) { + // splashoff + QDomDocument doc; + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + auto mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre( + _, _, _, _, std::chrono::milliseconds(0), _, _, _, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre( + _, _, _, _, std::chrono::milliseconds(500), _, _, _, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre(_, + _, + _, + _, + std::chrono::milliseconds(kMaxSplashOffDuration), + _, + _, + _, + _))); + EXPECT_LOG_MSG( + QtWarningMsg, + QString("Invalid splashoff duration. Splashoff duration must " + "be between 0 and %0. Clamping to %1") + .arg(kMaxSplashOffDuration) + .arg(kMaxSplashOffDuration)); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + ASSERT_ALL_EXPECTED_MSG(); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_LOG_MSG(QtWarningMsg, + QString("Unable to parse the field \"splashoff\" as an unsigned " + "integer in the screen definition.")); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre( + _, _, _, _, std::chrono::milliseconds(0), _, _, _, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); + + doc.setContent( + QByteArray(R"EOF( + + + + + + )EOF")); + + mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + EXPECT_LOG_MSG(QtWarningMsg, + QString("Unable to parse the field \"splashoff\" as an unsigned " + "integer in the screen definition.")); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre( + _, _, _, _, std::chrono::milliseconds(0), _, _, _, _))); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); +} + +TEST_F(LegacyControllerMappingFileHandlerTest, canParseHybridMapping) { + QDomDocument doc; + + doc.setContent(QByteArray(R"EOF( + + + + + + + + + + + + + )EOF")); + + auto mapping = std::make_shared(); + // This file always gets added + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("common-controller-scripts.js"), + QString(""), + _, // gmock seems unable to assert QFileInfo + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + true))); + + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("DummyDeviceDefaultScreen.qml"), + QString(""), + QFileInfo("/dummy/path/DummyDeviceDefaultScreen.qml"), + LegacyControllerMapping::ScriptFileInfo::Type::Qml, + false))); + EXPECT_CALL(*mapping, + addScriptFile(FieldsAre(QString("LegacyScript.js"), + QString(""), + QFileInfo("/dummy/path/LegacyScript.js"), + LegacyControllerMapping::ScriptFileInfo::Type::Javascript, + false))); + EXPECT_CALL(*mapping, + addScreenInfo(FieldsAre(QString("main"), + QSize(480, 360), + 20, + 1, + std::chrono::milliseconds(2000), + QImage::Format_RGBA8888, + LegacyControllerMapping::ScreenInfo::ColorEndian::Little, + false, + false))); + EXPECT_CALL(*mapping, addModule(QFileInfo("/dummy/path/foobar"), false)); + + addScriptFilesToMapping( + doc.documentElement(), + mapping, + QDir()); +} diff --git a/src/test/controller_mapping_validation_test.cpp b/src/test/controller_mapping_validation_test.cpp index 29be36d171c7..1ffbfad36285 100644 --- a/src/test/controller_mapping_validation_test.cpp +++ b/src/test/controller_mapping_validation_test.cpp @@ -1,9 +1,23 @@ #include "test/controller_mapping_validation_test.h" +#include +#include + #include #include "controllers/defs_controllers.h" #include "controllers/scripting/legacy/controllerscriptenginelegacy.h" +#ifdef MIXXX_USE_QML +#include "effects/effectsmanager.h" +#include "engine/channelhandle.h" +#include "engine/enginemixer.h" +#include "library/coverartcache.h" +#include "library/library.h" +#include "mixer/playerinfo.h" +#include "mixer/playermanager.h" +#include "qml/qmlplayermanagerproxy.h" +#include "soundio/soundmanager.h" +#endif #include "moc_controller_mapping_validation_test.cpp" FakeMidiControllerJSProxy::FakeMidiControllerJSProxy() @@ -107,10 +121,73 @@ bool FakeController::isMappable() const { return false; } +#ifdef MIXXX_USE_QML +void deleteTrack(Track* pTrack) { + // Delete track objects directly in unit tests with + // no main event loop + delete pTrack; +}; +#endif + void LegacyControllerMappingValidationTest::SetUp() { m_mappingPath = QDir::current(); m_mappingPath.cd("res/controllers"); m_pEnumerator.reset(new MappingInfoEnumerator(QList{m_mappingPath.absolutePath()})); +#ifdef MIXXX_USE_QML + // This setup mirrors coreservices -- it would be nice if we could use coreservices instead + // but it does a lot of local disk / settings setup. + auto pChannelHandleFactory = std::make_shared(); + m_pEffectsManager = std::make_shared(m_pConfig, pChannelHandleFactory); + m_pEngine = std::make_shared( + m_pConfig, + "[Master]", + m_pEffectsManager.get(), + pChannelHandleFactory, + true); + m_pSoundManager = std::make_shared(m_pConfig, m_pEngine.get()); + m_pControlIndicatorTimer = std::make_shared(nullptr); + m_pEngine->registerNonEngineChannelSoundIO(m_pSoundManager.get()); + m_pPlayerManager = std::make_shared(m_pConfig, + m_pSoundManager.get(), + m_pEffectsManager.get(), + m_pEngine.get()); + + m_pPlayerManager->addConfiguredDecks(); + m_pPlayerManager->addSampler(); + PlayerInfo::create(); + m_pEffectsManager->setup(); + + const auto dbConnection = mixxx::DbConnectionPooled(dbConnectionPooler()); + if (!MixxxDb::initDatabaseSchema(dbConnection)) { + exit(1); + } + m_pTrackCollectionManager = std::make_shared( + nullptr, + m_pConfig, + dbConnectionPooler(), + deleteTrack); + + m_pRecordingManager = std::make_shared(m_pConfig, m_pEngine.get()); + CoverArtCache::createInstance(); + m_pLibrary = std::make_shared( + nullptr, + m_pConfig, + dbConnectionPooler(), + m_pTrackCollectionManager.get(), + m_pPlayerManager.get(), + m_pRecordingManager.get()); + + m_pPlayerManager->bindToLibrary(m_pLibrary.get()); + mixxx::qml::QmlPlayerManagerProxy::registerPlayerManager(m_pPlayerManager); + ControllerScriptEngineBase::registerTrackCollectionManager(m_pTrackCollectionManager); +} + +void LegacyControllerMappingValidationTest::TearDown() { + PlayerInfo::destroy(); + CoverArtCache::destroy(); + mixxx::qml::QmlPlayerManagerProxy::registerPlayerManager(nullptr); + ControllerScriptEngineBase::registerTrackCollectionManager(nullptr); +#endif } bool LegacyControllerMappingValidationTest::testLoadMapping(const MappingInfo& mapping) { @@ -123,7 +200,7 @@ bool LegacyControllerMappingValidationTest::testLoadMapping(const MappingInfo& m FakeController controller; controller.setMapping(pMapping); - bool result = controller.applyMapping(); + bool result = controller.applyMapping("./res"); controller.stopEngine(); return result; } diff --git a/src/test/controller_mapping_validation_test.h b/src/test/controller_mapping_validation_test.h index da868da44cb1..b2596c51051a 100644 --- a/src/test/controller_mapping_validation_test.h +++ b/src/test/controller_mapping_validation_test.h @@ -2,11 +2,14 @@ #include +#include "control/controlindicatortimer.h" #include "controllers/controller.h" #include "controllers/controllermappinginfoenumerator.h" #include "controllers/hid/legacyhidcontrollermapping.h" #include "controllers/midi/legacymidicontrollermapping.h" -#include "test/mixxxtest.h" +#include "library/trackcollectionmanager.h" +#include "test/mixxxdbtest.h" +#include "test/soundsourceproviderregistration.h" class FakeMidiControllerJSProxy : public ControllerJSProxy { Q_OBJECT @@ -136,9 +139,41 @@ class FakeController : public Controller { std::shared_ptr m_pHidMapping; }; -class LegacyControllerMappingValidationTest : public MixxxTest { +class EngineMixer; +class EffectsManager; +class SoundManager; +class RecordingManager; +class Library; +class PlayerManager; + +// We can't inherit from LibraryTest because that creates a key_notation control object that is also +// created by the Library object itself. The duplicated CO creation causes a debug assert. +class LegacyControllerMappingValidationTest : public MixxxDbTest, SoundSourceProviderRegistration { + public: + LegacyControllerMappingValidationTest() + : MixxxDbTest(true) { + } + protected: void SetUp() override; +#ifdef MIXXX_USE_QML + void TearDown() override; + + TrackPointer getOrAddTrackByLocation( + const QString& trackLocation) const { + return m_pTrackCollectionManager->getOrAddTrack( + TrackRef::fromFilePath(trackLocation)); + } + + std::shared_ptr m_pEffectsManager; + std::shared_ptr m_pControlIndicatorTimer; + std::shared_ptr m_pEngine; + std::shared_ptr m_pSoundManager; + std::shared_ptr m_pPlayerManager; + std::shared_ptr m_pTrackCollectionManager; + std::shared_ptr m_pRecordingManager; + std::shared_ptr m_pLibrary; +#endif bool testLoadMapping(const MappingInfo& mapping); diff --git a/src/test/controllerrenderingengine_test.cpp b/src/test/controllerrenderingengine_test.cpp new file mode 100644 index 000000000000..864497861f15 --- /dev/null +++ b/src/test/controllerrenderingengine_test.cpp @@ -0,0 +1,51 @@ +#include "controllers/rendering/controllerrenderingengine.h" + +#include +#include + +#include +#include + +#include "controllers/controllerenginethreadcontrol.h" +#include "controllers/legacycontrollermappingfilehandler.h" +#include "helpers/log_test.h" +#include "test/mixxxtest.h" + +using ::testing::_; + +class ControllerRenderingEngineTest : public MixxxTest { + public: + void SetUp() override { + mixxx::Time::setTestMode(true); + mixxx::Time::setTestElapsedTime(mixxx::Duration::fromMillis(10)); + SETUP_LOG_CAPTURE(); + } + + QList supportedPixelFormat() const { + return LegacyControllerMappingFileHandler::kSupportedPixelFormat.values(); + } +}; + +class MockRenderingEngine : public ControllerRenderingEngine { + public: + MockRenderingEngine(const LegacyControllerMapping::ScreenInfo& info) + : ControllerRenderingEngine(info, new ControllerEngineThreadControl){}; +}; + +TEST_F(ControllerRenderingEngineTest, createValidRendererWithSupportedTypes) { + for (auto pixelFormat : supportedPixelFormat()) { + MockRenderingEngine screenTest(LegacyControllerMapping::ScreenInfo{ + "", // identifier + QSize(0, 0), // size + 10, // target_fps + 1, // msaa + std::chrono::milliseconds(10), // splash_off + pixelFormat, // pixelFormat + LegacyControllerMapping::ScreenInfo::ColorEndian::Big, // endian + false, // reversedColor + false // rawData + }); + EXPECT_TRUE(screenTest.isValid()); + EXPECT_TRUE(screenTest.stop()); + } +} diff --git a/src/test/controllerscriptenginelegacy_test.cpp b/src/test/controllerscriptenginelegacy_test.cpp index df55b0724129..9fd4c5e34f42 100644 --- a/src/test/controllerscriptenginelegacy_test.cpp +++ b/src/test/controllerscriptenginelegacy_test.cpp @@ -1,25 +1,41 @@ #include "controllers/scripting/legacy/controllerscriptenginelegacy.h" +#include +#include + #include #include #include #include +#include #include #include "control/controlobject.h" #include "control/controlpotmeter.h" +#ifdef MIXXX_USE_QML +#include + +#include "controllers/controllerenginethreadcontrol.h" +#include "controllers/rendering/controllerrenderingengine.h" +#endif #include "controllers/softtakeover.h" +#include "helpers/log_test.h" #include "preferences/usersettings.h" #include "test/mixxxtest.h" #include "util/color/colorpalette.h" #include "util/time.h" +using ::testing::_; + typedef std::unique_ptr ScopedTemporaryFile; const RuntimeLoggingCategory logger(QString("test").toLocal8Bit()); -class ControllerScriptEngineLegacyTest : public MixxxTest { +class ControllerScriptEngineLegacyTest : public ControllerScriptEngineLegacy, public MixxxTest { protected: + ControllerScriptEngineLegacyTest() + : ControllerScriptEngineLegacy(nullptr, logger) { + } static ScopedTemporaryFile makeTemporaryFile(const QString& contents) { QByteArray contentsBa = contents.toLocal8Bit(); ScopedTemporaryFile pFile = std::make_unique(); @@ -33,21 +49,19 @@ class ControllerScriptEngineLegacyTest : public MixxxTest { mixxx::Time::setTestMode(true); mixxx::Time::setTestElapsedTime(mixxx::Duration::fromMillis(10)); QThread::currentThread()->setObjectName("Main"); - cEngine = new ControllerScriptEngineLegacy(nullptr, logger); - cEngine->initialize(); + initialize(); } void TearDown() override { - delete cEngine; mixxx::Time::setTestMode(false); } bool evaluateScriptFile(const QFileInfo& scriptFile) { - return cEngine->evaluateScriptFile(scriptFile); + return ControllerScriptEngineLegacy::evaluateScriptFile(scriptFile); } QJSValue evaluate(const QString& code) { - return cEngine->jsEngine()->evaluate(code); + return jsEngine()->evaluate(code); } bool evaluateAndAssert(const QString& code) { @@ -65,7 +79,31 @@ class ControllerScriptEngineLegacyTest : public MixxxTest { application()->processEvents(); } - ControllerScriptEngineLegacy* cEngine; +#ifdef MIXXX_USE_QML + QHash& transformScreenFrameFunctions() { + return m_transformScreenFrameFunctions; + } + + QHash>& renderingScreens() { + return m_renderingScreens; + } + + QHash>& rootItems() { + return m_rootItems; + } + + void testHandleScreen( + const LegacyControllerMapping::ScreenInfo& screeninfo, + const QImage& frame, + const QDateTime& timestamp) { + handleScreenFrame(screeninfo, frame, timestamp); + } + + TransformScreenFrameFunction newTransformScreenFrameFunction( + QMetaMethod method, bool typed) const { + return TransformScreenFrameFunction{method, typed}; + } +#endif }; TEST_F(ControllerScriptEngineLegacyTest, commonScriptHasNoErrors) { @@ -620,3 +658,90 @@ TEST_F(ControllerScriptEngineLegacyTest, connectionExecutesWithCorrectThisObject // The counter should have been incremented exactly once. EXPECT_DOUBLE_EQ(1.0, pass->get()); } + +#ifdef MIXXX_USE_QML +class MockScreenRender : public ControllerRenderingEngine { + public: + MockScreenRender(const LegacyControllerMapping::ScreenInfo& info) + : ControllerRenderingEngine(info, new ControllerEngineThreadControl){}; + MOCK_METHOD(void, + requestSendingFrameData, + (Controller * controller, const QByteArray& frame), + (override)); +}; + +TEST_F(ControllerScriptEngineLegacyTest, screenWontSentRawDataIfNotConfigured) { + SETUP_LOG_CAPTURE(); + LegacyControllerMapping::ScreenInfo dummyScreen{ + "", // identifier + QSize(0, 0), // size + 10, // target_fps + 1, // msaa + std::chrono::milliseconds(10), // splash_off + QImage::Format_RGB16, // pixelFormat + LegacyControllerMapping::ScreenInfo::ColorEndian::Big, // endian + false, // rawData + false // reversedColor + }; + QImage dummyFrame; + // Allocate screen on the heap as it need to outlive the this function, + // since the engine will take ownership of it + std::shared_ptr pDummyRender = + std::make_shared(dummyScreen); + EXPECT_CALL(*pDummyRender, requestSendingFrameData(_, _)).Times(0); + EXPECT_LOG_MSG(QtWarningMsg, + "Could not find a valid transform function but the screen doesn't " + "accept raw data. Aborting screen rendering."); + + transformScreenFrameFunctions().insert( + dummyScreen.identifier, + newTransformScreenFrameFunction( + QMetaMethod(), + false)); + renderingScreens().insert(dummyScreen.identifier, pDummyRender); + rootItems().insert(dummyScreen.identifier, std::make_shared()); + + testHandleScreen( + dummyScreen, + dummyFrame, + QDateTime::currentDateTime()); + + ASSERT_ALL_EXPECTED_MSG(); +} + +TEST_F(ControllerScriptEngineLegacyTest, screenWillSentRawDataIfConfigured) { + SETUP_LOG_CAPTURE(); + LegacyControllerMapping::ScreenInfo dummyScreen{ + "", // identifier + QSize(0, 0), // size + 10, // target_fps + 1, // msaa + std::chrono::milliseconds(10), // splash_off + QImage::Format_RGB16, // pixelFormat + LegacyControllerMapping::ScreenInfo::ColorEndian::Big, // endian + false, // reversedColor + true // rawData + }; + QImage dummyFrame; + // Allocate screen on the heap as it need to outlive the this function, + // since the engine will take ownership of it + std::shared_ptr pDummyRender = + std::make_shared(dummyScreen); + EXPECT_CALL(*pDummyRender, requestSendingFrameData(_, QByteArray())); + + transformScreenFrameFunctions().insert( + dummyScreen.identifier, + newTransformScreenFrameFunction( + QMetaMethod(), + false)); + renderingScreens().insert(dummyScreen.identifier, pDummyRender); + rootItems().insert(dummyScreen.identifier, std::make_shared()); + + testHandleScreen( + dummyScreen, + dummyFrame, + QDateTime::currentDateTime()); + + ASSERT_ALL_EXPECTED_MSG(); +} +#endif diff --git a/src/test/helpers/log_test.cpp b/src/test/helpers/log_test.cpp new file mode 100644 index 000000000000..63acdbdfeb1d --- /dev/null +++ b/src/test/helpers/log_test.cpp @@ -0,0 +1,22 @@ +#include "test/helpers/log_test.h" + +QList> logMessagesExpected; + +void logCapture(QtMsgType msgType, const QMessageLogContext&, const QString& msg) { + for (int i = 0; i < logMessagesExpected.size(); i++) { + if (std::get(logMessagesExpected[i]) == msgType && + std::get(logMessagesExpected[i]) + .match(msg) + .hasMatch()) { + logMessagesExpected.removeAt(i); + return; + } + } + // Unexpected info or debug message aren't considered as a failure + if (msgType < QtMsgType::QtWarningMsg) + return; + QString errMsg("Got an unexpected log message: \n\t"); + QDebug strm(&errMsg); + strm << msgType << msg; + FAIL() << errMsg.toStdString(); +} diff --git a/src/test/helpers/log_test.h b/src/test/helpers/log_test.h new file mode 100644 index 000000000000..10f3cbb5936c --- /dev/null +++ b/src/test/helpers/log_test.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#define SETUP_LOG_CAPTURE() \ + qInstallMessageHandler(logCapture) + +#define ASSERT_ALL_EXPECTED_MSG() \ + if (!logMessagesExpected.isEmpty()) { \ + QString errMsg; \ + QDebug strm(&errMsg); \ + strm << logMessagesExpected.size() << "expected log messages didn't occur: \n"; \ + for (const auto& msg : std::as_const(logMessagesExpected)) \ + strm << "\t" << std::get(msg) << std::get(msg) << "\n"; \ + FAIL() << errMsg.toStdString(); \ + } else \ + logMessagesExpected.clear(); + +#define EXPECT_LOG_MSG(type, exp) \ + logMessagesExpected.push_back(std::make_tuple(type, QRegularExpression(exp))) + +extern QList> logMessagesExpected; +void logCapture(QtMsgType msgType, const QMessageLogContext&, const QString& msg); diff --git a/src/util/cmdlineargs.cpp b/src/util/cmdlineargs.cpp index 9636b1028fda..6d0d987fd461 100644 --- a/src/util/cmdlineargs.cpp +++ b/src/util/cmdlineargs.cpp @@ -340,6 +340,12 @@ bool CmdlineArgs::parse(const QStringList& arguments, CmdlineArgs::ParseMode mod "you specify will be loaded into the next virtual deck.") : QString()); + const QCommandLineOption controllerPreviewScreens(QStringLiteral("controller-preview-screens"), + forUserFeedback ? QCoreApplication::translate("CmdlineArgs", + "Preview rendered controller screens in the Setting windows.") + : QString()); + parser.addOption(controllerPreviewScreens); + if (forUserFeedback) { // We know form the first path, that there will be likely an error message, check again. // This is not the case if the user uses a Qt internal option that is unknown @@ -408,6 +414,7 @@ bool CmdlineArgs::parse(const QStringList& arguments, CmdlineArgs::ParseMode mod m_useLegacyVuMeter = parser.isSet(enableLegacyVuMeter); m_useLegacySpinny = parser.isSet(enableLegacySpinny); m_controllerDebug = parser.isSet(controllerDebug) || parser.isSet(controllerDebugDeprecated); + m_controllerPreviewScreens = parser.isSet(controllerPreviewScreens); m_controllerAbortOnWarning = parser.isSet(controllerAbortOnWarning); m_developer = parser.isSet(developer); #ifdef MIXXX_USE_QML diff --git a/src/util/cmdlineargs.h b/src/util/cmdlineargs.h index c31b91ba6709..5032d2ccb18c 100644 --- a/src/util/cmdlineargs.h +++ b/src/util/cmdlineargs.h @@ -36,6 +36,9 @@ class CmdlineArgs final { bool getControllerDebug() const { return m_controllerDebug; } + bool getControllerPreviewScreens() const { + return m_controllerPreviewScreens; + } bool getControllerAbortOnWarning() const { return m_controllerAbortOnWarning; } @@ -87,6 +90,7 @@ class CmdlineArgs final { bool m_startInFullscreen; // Start in fullscreen mode bool m_startAutoDJ; bool m_controllerDebug; + bool m_controllerPreviewScreens; bool m_controllerAbortOnWarning; // Controller Engine will be stricter bool m_developer; // Developer Mode #ifdef MIXXX_USE_QML diff --git a/src/util/thread_affinity.h b/src/util/thread_affinity.h index 22f5919a0a25..04d1f77c8964 100644 --- a/src/util/thread_affinity.h +++ b/src/util/thread_affinity.h @@ -17,3 +17,15 @@ /// thread of the application. #define DEBUG_ASSERT_MAIN_THREAD_AFFINITY() \ DEBUG_ASSERT_QOBJECT_THREAD_AFFINITY(QCoreApplication::instance()) + +#define DEBUG_ASSERT_THIS_QOBJECT_THREAD_ANTI_AFFINITY() \ + DEBUG_ASSERT(thread() != QThread::currentThread()) + +#define VERIFY_OR_DEBUG_ASSERT_THIS_QOBJECT_THREAD_ANTI_AFFINITY() \ + VERIFY_OR_DEBUG_ASSERT(thread() != QThread::currentThread()) + +#define DEBUG_ASSERT_THIS_QOBJECT_THREAD_AFFINITY() \ + DEBUG_ASSERT(thread() == QThread::currentThread()) + +#define VERIFY_OR_DEBUG_ASSERT_THIS_QOBJECT_THREAD_AFFINITY() \ + VERIFY_OR_DEBUG_ASSERT(thread() == QThread::currentThread()) diff --git a/tools/README b/tools/README index 8aa1a4a36ea0..875579939de5 100644 --- a/tools/README +++ b/tools/README @@ -1,2 +1,22 @@ This directory is for developer helper scripts, like those used to automate the creation of controller layouts. + + +## Dummy HID Device (Linux only) + +If you ever need a dummy HID device to test mapping capabilities or introspect reports set by a mapping for example, you can build a dummy one using `dummy_hid_device.c`. Note that you will need `uhid` to use this daemon. + +Here is how to get the daemon setup. Make sure to do this **before** Mixxx is started + +```sh +# Enable UHID +sudo modprobe uhid +# This next command assumes you are running it from the Mixxx directory +# Optionally, you can pass: +# `-DVENDOR_ID=0x1234` to customize vendor ID +# `-DPRODUCT_ID=0x1234` to customize product ID +# `-DDEVICE_NAME="My Device"` to customize the device name +cd build && gcc ../tools/dummy_hid_device.c -lhidapi-hidraw -o dummy_hid_device && sudo ./dummy_hid_device +# Allow the created hidraw device to be accesssed by the user. You may also set the write udev rules. Finally, you can also run Mixxx as root, but that's not recommended. +sudo chown "$USER" "$(ls -1t /dev/hidraw* | head -n 1)" +``` diff --git a/tools/dummy_hid_device.c b/tools/dummy_hid_device.c new file mode 100644 index 000000000000..ec3c392c5094 --- /dev/null +++ b/tools/dummy_hid_device.c @@ -0,0 +1,251 @@ +#define _DEFAULT_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef VENDOR_ID +#define VENDOR_ID 0xDEAD +#endif +#ifndef PRODUCT_ID +#define PRODUCT_ID 0xBEAF +#endif +#ifndef DEVICE_NAME +#define DEVICE_NAME "test-uhid-device" +#endif + +static volatile u_int32_t done; + +static unsigned char rdesc[] = { + 0x06, 0x02, 0xFF, /* Usage Page (FF02h), */ + 0x09, + 0x00, /* Usage (00h), */ + 0xA1, + 0x01, /* Collection (Application), */ + 0x09, + 0x01, /* Usage (01h), */ + 0xA1, + 0x02, /* Collection (Logical), */ + 0x85, + 0x01, /* Report ID (1), */ + 0x09, + 0x02, /* Usage (02h), */ + 0x15, + 0x00, /* Logical Minimum (0), */ + 0x25, + 0x01, /* Logical Maximum (1), */ + 0x75, + 0x01, /* Report Size (1), */ + 0x95, + 0x88, /* Report Count (136), */ + 0x81, + 0x02, /* Input (Variable), */ + 0x09, + 0x07, /* Usage (07h), */ + 0x15, + 0x00, /* Logical Minimum (0), */ + 0x25, + 0x01, /* Logical Maximum (1), */ + 0x75, + 0x01, /* Report Size (1), */ + 0x95, + 0x10, /* Report Count (16), */ + 0x81, + 0x02, /* Input (Variable), */ + 0x09, + 0x03, /* Usage (03h), */ + 0x15, + 0x00, /* Logical Minimum (0), */ + 0x25, + 0x0F, /* Logical Maximum (15), */ + 0x75, + 0x04, /* Report Size (4), */ + 0x95, + 0x06, /* Report Count (6), */ + 0x81, + 0x02, /* Input (Variable), */ + 0xC0, /* End Collection, */ + 0xC0 /* End Collection */ +}; + +void sighndlr(int signal) { + done = 1; + printf("\n"); +} + +static int uhid_write(int fd, const struct uhid_event* ev) { + ssize_t ret; + + ret = write(fd, ev, sizeof(*ev)); + if (ret < 0) { + fprintf(stderr, "Cannot write to uhid: %s\n", strerror(errno)); + return -errno; + } else if (ret != sizeof(*ev)) { + fprintf(stderr, "Wrong size written to uhid: %zd != %zu\n", ret, sizeof(ev)); + return -1; + } else { + return 0; + } +} + +static int create(int fd) { + struct uhid_event ev; + + memset(&ev, 0, sizeof(ev)); + ev.type = UHID_CREATE; + strcpy((char*)ev.u.create.name, DEVICE_NAME); + ev.u.create.rd_data = rdesc; + ev.u.create.rd_size = sizeof(rdesc); + ev.u.create.bus = BUS_USB; + ev.u.create.vendor = VENDOR_ID; + ev.u.create.product = PRODUCT_ID; + ev.u.create.version = 0; + ev.u.create.country = 0; + + return uhid_write(fd, &ev); +} + +static void destroy(int fd) { + struct uhid_event ev; + + memset(&ev, 0, sizeof(ev)); + ev.type = UHID_DESTROY; + + uhid_write(fd, &ev); +} + +/* This parses raw output reports sent by the kernel to the device. A normal + * uhid program shouldn't do this but instead just forward the raw report. + * However, for ducomentational purposes, we try to detect LED events here and + * print debug messages for it. */ +static void handle_output(struct uhid_event* ev) { + if (ev->u.output.rtype != UHID_OUTPUT_REPORT) + return; + + fprintf(stderr, + "Output received of type %d and size %d: %s\n", + ev->u.output.rtype, + ev->u.output.size, + ev->u.output.data); + for (int i = 0; i < ev->u.output.size; i++) { + printf("%02X ", ev->u.output.data[i]); + } + printf("\n"); + return; +} +static void handle_report(struct uhid_event* ev, int fd) { + fprintf(stderr, "RType is %d\n", ev->u.get_report.rtype); + if (ev->u.get_report.rtype != UHID_START) + return; + + fprintf(stderr, "Get report for %d\n", ev->u.get_report.rnum); + + struct uhid_event rep; + + memset(&rep, 0, sizeof(rep)); + rep.type = UHID_GET_REPORT_REPLY; + + rep.u.get_report_reply.id = ev->u.get_report.id; + rep.u.get_report_reply.err = 0; + rep.u.get_report_reply.size = 255; + memset(rep.u.get_report_reply.data, 0, UHID_DATA_MAX); + + uhid_write(fd, &rep); + return; +} + +static int event(int fd) { + struct uhid_event ev; + ssize_t ret; + + memset(&ev, 0, sizeof(ev)); + ret = read(fd, &ev, sizeof(ev)); + if (ret == 0) { + fprintf(stderr, "Read HUP on uhid-cdev\n"); + return -1; + } else if (ret < 0) { + fprintf(stderr, "Cannot read uhid-cdev\n"); + return -errno; + } else if (ret != sizeof(ev)) { + fprintf(stderr, "Invalid size read from uhid-dev: %zd != %zu\n", ret, sizeof(ev)); + return -1; + } + + switch (ev.type) { + case UHID_START: + fprintf(stderr, "UHID_START from uhid-dev\n"); + break; + case UHID_STOP: + fprintf(stderr, "UHID_STOP from uhid-dev\n"); + break; + case UHID_OPEN: + fprintf(stderr, "UHID_OPEN from uhid-dev\n"); + break; + case UHID_CLOSE: + fprintf(stderr, "UHID_CLOSE from uhid-dev\n"); + case UHID_OUTPUT: + fprintf(stderr, "UHID_OUTPUT from uhid-dev\n"); + handle_output(&ev); + break; + case UHID_OUTPUT_EV: + fprintf(stderr, "UHID_OUTPUT_EV from uhid-dev\n"); + break; + case UHID_GET_REPORT: + fprintf(stderr, "UHID_GET_REPORT from uhid-dev\n"); + handle_report(&ev, fd); + break; + default: + fprintf(stderr, "Invalid event from uhid-dev: %u\n", ev.type); + } + + return 0; +} + +int main(int argc, char** argv) { + signal(SIGINT, sighndlr); + + // UHID + int fd = open("/dev/uhid", O_RDWR); + if (fd < 0) { + perror("open"); + return -1; + } + + fprintf(stderr, "Create uhid device\n"); + if (create(fd)) { + close(fd); + return EXIT_FAILURE; + } + + struct pollfd pfds; + pfds.fd = fd; + pfds.events = POLLIN; + + while (!done) { + int ret = poll(&pfds, 1, 10); + if (ret < 0) { + fprintf(stderr, "Cannot poll for fds: %m\n"); + break; + } + if (pfds.revents & POLLHUP) { + fprintf(stderr, "Received HUP on uhid-cdev\n"); + break; + } + if (pfds.revents & POLLIN) { + ret = event(fd); + if (ret) + break; + } + } + + destroy(fd); + + return 0; +}