Moving on, we are done with the C++ side of things and can now look at the QML UI.
First, here is the main QML file:
import QtQuick 2.0 import QtQuick.Scene3D 2.0 import QtQuick.Layouts 1.2 import QtMultimedia 5.0 Item { id: mainview width: 1215 height: 720 visible: true property bool isHoverEnabled: false property int mediaLatencyOffset: 68
The QML file consists out of a hierarchy of elements. Here, we define the top element, giving it its dimensions and name:
state: "stopped" states: [ State { name: "playing" PropertyChanges { target: playButtonImage source: { if (playButtonMouseArea.containsMouse) "qrc:/images/pausehoverpressed.png" else "qrc:/images/pausenormal.png" } } PropertyChanges { target: stopButtonImage source: "qrc:/images/stopnormal.png" } }, State { name: "paused" PropertyChanges { target: playButtonImage source: { if (playButtonMouseArea.containsMouse) "qrc:/images/playhoverpressed.png" else "qrc:/images/playnormal.png" } } PropertyChanges { target: stopButtonImage source: "qrc:/images/stopnormal.png" } }, State { name: "stopped" PropertyChanges { target: playButtonImage source: "qrc:/images/playnormal.png" } PropertyChanges { target: stopButtonImage source: "qrc:/images/stopdisabled.png" } } ]
A number of states for the UI are defined, along with the changes that should be triggered if the state should change to it:
Connections { target: qmlinterface onStopped: mainview.state = "stopped" onPaused: mainview.state = "paused" onPlaying: mainview.state = "started" onStart: visualizer.startVisualization() }
These are the connections that link the signals from the C++ side to a local handler. We target our custom class as the source of these signals, then define the handler for each signal we wish to handle by prefixing it and adding the code that should be executed.
Here, we see that the start signal is linked to a handler that triggers the function in the visualization module that starts that module:
Component.onCompleted: isHoverEnabled = qmlinterface.isHoverEnabled() Image { id: coverImage anchors.fill: parent source: "qrc:/images/albumcover.png" }
This Image element defines the background image, which we load from the resources that were added to the executable when the project was built:
Scene3D { anchors.fill: parent Visualizer { id: visualizer animationState: mainview.state numberOfBars: 120 barRotationTimeMs: 8160 // 68 ms per bar } }
The 3D scene that will be filled with the visualizer's content is defined:
Rectangle { id: blackBottomRect color: "black" width: parent.width height: 0.14 * mainview.height anchors.bottom: parent.bottom } Text { text: qmlinterface.durationTotal color: "#80C342" x: parent.width / 6 y: mainview.height - mainview.height / 8 font.pixelSize: 12 } Text { text: qmlinterface.durationLeft color: "#80C342" x: parent.width - parent.width / 6 y: mainview.height - mainview.height / 8 font.pixelSize: 12 }
These two text elements are linked with the property in our custom C++ class, as we saw earlier. These values will be kept updated with the value in the C++ class instance as it changes:
property int buttonHorizontalMargin: 10 Rectangle { id: playButton height: 54 width: 54 anchors.bottom: parent.bottom anchors.bottomMargin: width x: parent.width / 2 - width - buttonHorizontalMargin color: "transparent" Image { id: playButtonImage source: "qrc:/images/pausenormal.png" } MouseArea { id: playButtonMouseArea anchors.fill: parent hoverEnabled: isHoverEnabled onClicked: { if (mainview.state == 'paused' || mainview.state == 'stopped') mainview.state = 'playing' else mainview.state = 'paused' } onEntered: { if (mainview.state == 'playing') playButtonImage.source = "qrc:/images/pausehoverpressed.png" else playButtonImage.source = "qrc:/images/playhoverpressed.png" } onExited: { if (mainview.state == 'playing') playButtonImage.source = "qrc:/images/pausenormal.png" else playButtonImage.source = "qrc:/images/playnormal.png" } } } Rectangle { id: stopButton height: 54 width: 54 anchors.bottom: parent.bottom anchors.bottomMargin: width x: parent.width / 2 + buttonHorizontalMargin color: "transparent" Image { id: stopButtonImage source: "qrc:/images/stopnormal.png" } MouseArea { anchors.fill: parent hoverEnabled: isHoverEnabled onClicked: mainview.state = 'stopped' onEntered: { if (mainview.state != 'stopped') stopButtonImage.source = "qrc:/images/stophoverpressed.png" } onExited: { if (mainview.state != 'stopped') stopButtonImage.source = "qrc:/images/stopnormal.png" } } } }
The rest of the source serves to set up the individual buttons for controlling the playback, with play, stop, and pause buttons, which get swapped over as needed.
Next, we will look at the amplitude bar file:
import Qt3D.Core 2.0 import Qt3D.Render 2.0 import Qt3D.Extras 2.0 import QtQuick 2.4 as QQ2 Entity { property int rotationTimeMs: 0 property int entityIndex: 0 property int entityCount: 0 property int startAngle: 0 + 360 / entityCount * entityIndex property bool needsNewMagnitude: true property real magnitude: 0 property real animWeight: 0 property color lowColor: "black" property color highColor: "#b3b3b3" property color barColor: lowColor property string entityAnimationsState: "stopped" property bool entityAnimationsPlaying: true property var entityMesh: null
A number of properties are defined before we dive into the animation state change handler:
onEntityAnimationsStateChanged: { if (animationState == "paused") { if (angleAnimation.running) angleAnimation.pause() if (barColorAnimations.running) barColorAnimations.pause() } else if (animationState == "playing"){ needsNewMagnitude = true; if (heightDecreaseAnimation.running) heightDecreaseAnimation.stop() if (angleAnimation.paused) { angleAnimation.resume() } else if (!entityAnimationsPlaying) { magnitude = 0 angleAnimation.start() entityAnimationsPlaying = true } if (barColorAnimations.paused) barColorAnimations.resume() } else { if (animWeight != 0) heightDecreaseAnimation.start() needsNewMagnitude = true angleAnimation.stop() barColorAnimations.stop() entityAnimationsPlaying = false } }
Every time the audio playback is stopped, paused, or started, the animation has to be updated to match this state change:
property Material barMaterial: PhongMaterial { diffuse: barColor ambient: Qt.darker(barColor) specular: "black" shininess: 1 }
This defines the look of the amplitude bars, using Phong shading:
property Transform angleTransform: Transform { property real heightIncrease: magnitude * animWeight property real barAngle: startAngle matrix: { var m = Qt.matrix4x4() m.rotate(barAngle, Qt.vector3d(0, 1, 0)) m.translate(Qt.vector3d(1.1, heightIncrease / 2 - heightIncrease * 0.05, 0)) m.scale(Qt.vector3d(0.5, heightIncrease * 15, 0.5)) return m; } property real compareAngle: barAngle onBarAngleChanged: { compareAngle = barAngle if (compareAngle > 360) compareAngle = barAngle - 360 if (compareAngle > 180) { parent.enabled = false animWeight = 0 if (needsNewMagnitude) { // Calculate the ms offset where the bar will be at the center point of the // visualization and fetch the correct magnitude for that point in time. var offset = (90.0 + 360.0 - compareAngle) * (rotationTimeMs / 360.0) magnitude = qmlinterface.getNextAudioLevel(offset) needsNewMagnitude = false } } else { parent.enabled = true // Calculate a power of 2 curve for the bar animation that peaks at 90 degrees animWeight = Math.min((compareAngle / 90), (180 - compareAngle) / 90) animWeight = animWeight * animWeight if (!needsNewMagnitude) { needsNewMagnitude = true barColorAnimations.start() } } } }
As the amplitude bars move across the screen, they change relative to the camera, so we need to keep calculating the new angle and display height.
In this section, we also replaced the original call to the audio level method with a call to the new method in our C++ class:
components: [entityMesh, barMaterial, angleTransform] QQ2.NumberAnimation { id: angleAnimation target: angleTransform property: "barAngle" duration: rotationTimeMs loops: QQ2.Animation.Infinite running: true from: startAngle to: 360 + startAngle } QQ2.NumberAnimation { id: heightDecreaseAnimation target: angleTransform property: "heightIncrease" duration: 400 running: false from: angleTransform.heightIncrease to: 0 onStopped: barColor = lowColor } property int animationDuration: angleAnimation.duration / 6 QQ2.SequentialAnimation on barColor { id: barColorAnimations running: false QQ2.ColorAnimation { from: lowColor to: highColor duration: animationDuration } QQ2.PauseAnimation { duration: animationDuration } QQ2.ColorAnimation { from: highColor to: lowColor duration: animationDuration } } }
The rest of the file contains a few more animation transforms.
Finally, here is the visualization module:
import Qt3D.Core 2.0 import Qt3D.Render 2.0 import Qt3D.Extras 2.0 import QtQuick 2.2 as QQ2 Entity { id: sceneRoot property int barRotationTimeMs: 1 property int numberOfBars: 1 property string animationState: "stopped" property real titleStartAngle: 95 property real titleStopAngle: 5 onAnimationStateChanged: { if (animationState == "playing") { qmlinterface.setPlaying(); if (progressTransformAnimation.paused) progressTransformAnimation.resume() else progressTransformAnimation.start() } else if (animationState == "paused") { qmlinterface.setPaused(); if (progressTransformAnimation.running) progressTransformAnimation.pause() } else { qmlinterface.setStopped(); progressTransformAnimation.stop() progressTransform.progressAngle = progressTransform.defaultStartAngle } }
This section got changed from interacting with the local media player instance to the new one in the C++ code. Beyond that, we left it unchanged. This is the main handler for when anything changes in the scene due to user interaction, or a track starting or ending:
QQ2.Item { id: stateItem state: animationState states: [ QQ2.State { name: "playing" QQ2.PropertyChanges { target: titlePrism titleAngle: titleStopAngle } }, QQ2.State { name: "paused" QQ2.PropertyChanges { target: titlePrism titleAngle: titleStopAngle } }, QQ2.State { name: "stopped" QQ2.PropertyChanges { target: titlePrism titleAngle: titleStartAngle } } ] transitions: QQ2.Transition { QQ2.NumberAnimation { property: "titleAngle" duration: 2000 running: false } } }
A number of property changes and transitions are defined for the track title object:
function startVisualization() { progressTransformAnimation.duration = qmlinterface.duration() mainview.state = "playing" progressTransformAnimation.start() }
This function is what starts the entire visualization sequence. It uses the track duration, as obtained via our C++ class instance, to determine the dimensions of the progress bar for the track playback animation before starting the visualization animation:
Camera { id: camera projectionType: CameraLens.PerspectiveProjection fieldOfView: 45 aspectRatio: 1820 / 1080 nearPlane: 0.1 farPlane: 1000.0 position: Qt.vector3d(0.014, 0.956, 2.178) upVector: Qt.vector3d(0.0, 1.0, 0.0) viewCenter: Qt.vector3d(0.0, 0.7, 0.0) }
A camera is defined for the 3D scene:
Entity { components: [ DirectionalLight { intensity: 0.9 worldDirection: Qt.vector3d(0, 0.6, -1) } ] } RenderSettings { id: external_forward_renderer activeFrameGraph: ForwardRenderer { camera: camera clearColor: "transparent" } }
A renderer and light for the scene are created:
components: [external_forward_renderer] CuboidMesh { id: barMesh xExtent: 0.1 yExtent: 0.1 zExtent: 0.1 }
A mesh is created for the amplitude bars:
NodeInstantiator { id: collection property int maxCount: parent.numberOfBars model: maxCount delegate: BarEntity { id: cubicEntity entityMesh: barMesh rotationTimeMs: sceneRoot.barRotationTimeMs entityIndex: index entityCount: sceneRoot.numberOfBars entityAnimationsState: animationState magnitude: 0 } }
The number of bars, along with other properties, is defined:
Entity { id: titlePrism property real titleAngle: titleStartAngle Entity { id: titlePlane PlaneMesh { id: titlePlaneMesh width: 550 height: 100 } Transform { id: titlePlaneTransform scale: 0.003 translation: Qt.vector3d(0, 0.11, 0) } NormalDiffuseMapAlphaMaterial { id: titlePlaneMaterial diffuse: TextureLoader { source: "qrc:/images/demotitle.png" } normal: TextureLoader { source: "qrc:/images/normalmap.png" } shininess: 1.0 } components: [titlePlaneMesh, titlePlaneMaterial, titlePlaneTransform] }
This plane contains the title object whenever there's no track playing:
Entity { id: songTitlePlane PlaneMesh { id: songPlaneMesh width: 550 height: 100 } Transform { id: songPlaneTransform scale: 0.003 rotationX: 90 translation: Qt.vector3d(0, -0.03, 0.13) } property Material songPlaneMaterial: NormalDiffuseMapAlphaMaterial { diffuse: TextureLoader { source: "qrc:/images/songtitle.png" } normal: TextureLoader { source: "qrc:/images/normalmap.png" } shininess: 1.0 } components: [songPlaneMesh, songPlaneMaterial, songPlaneTransform] }
This plane contains the song title whenever a track is active:
property Transform titlePrismPlaneTransform: Transform { matrix: { var m = Qt.matrix4x4() m.translate(Qt.vector3d(-0.5, 1.3, -0.4)) m.rotate(titlePrism.titleAngle, Qt.vector3d(1, 0, 0)) return m; } } components: [titlePlane, songTitlePlane, titlePrismPlaneTransform] }
To transform the planes between playing and non-playing transitions, this transform is used:
Mesh { id: circleMesh source: "qrc:/meshes/circle.obj" } Entity { id: circleEntity property Material circleMaterial: PhongAlphaMaterial { alpha: 0.4 ambient: "black" diffuse: "black" specular: "black" shininess: 10000 } components: [circleMesh, circleMaterial] }
A circle mesh that provides a reflection effect is added:
Mesh { id: progressMesh source: "qrc:/meshes/progressbar.obj" } Transform { id: progressTransform property real defaultStartAngle: -90 property real progressAngle: defaultStartAngle rotationY: progressAngle } Entity { property Material progressMaterial: PhongMaterial { ambient: "purple" diffuse: "white" } components: [progressMesh, progressMaterial, progressTransform] } QQ2.NumberAnimation { id: progressTransformAnimation target: progressTransform property: "progressAngle" duration: 0 running: false from: progressTransform.defaultStartAngle to: -270 onStopped: if (animationState != "stopped") animationState = "stopped" } }
Finally, this mesh creates the progress bar, which moves from the left to the right to indicate playback progress.
The entire project is compiled by running qmake followed by make, or by opening the project in Qt Creator and building it from there. When run, it will automatically start playing the included song and show the amplitude visualization, while being controllable via the buttons in the UI.