diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5812cd6cf442..f9c7f3edf76a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -126,7 +126,7 @@ repos: - id: prettier types: [yaml] - repo: https://github.com/qarmin/qml_formatter.git - rev: 16f651d727652dffff92678f4b602df9bfb45eb7 # No release tag yet including #7 fix + rev: 706250038bb565f4c630ca3aab09f764faabae67 # No release tag yet including #9 fix hooks: - id: qml_formatter - repo: https://github.com/BlankSpruce/gersemi diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c3fb67dd3c1..e70c0eb60456 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3455,6 +3455,7 @@ if(QML) src/qml/qmlwaveformrenderer.cpp src/qml/qmlsettingparameter.cpp src/qml/qmltrackproxy.cpp + src/qml/qmlsoundmanagerproxy.cpp src/waveform/renderers/allshader/digitsrenderer.cpp src/waveform/renderers/allshader/waveformrenderbeat.cpp src/waveform/renderers/allshader/waveformrenderer.cpp @@ -4120,6 +4121,9 @@ if(RUBBERBAND) find_package(rubberband REQUIRED) target_link_libraries(mixxx-lib PRIVATE rubberband::rubberband) target_compile_definitions(mixxx-lib PUBLIC __RUBBERBAND__) + if(QML) + target_compile_definitions(mixxx-qml-lib PUBLIC __RUBBERBAND__) + endif() target_sources( mixxx-lib PRIVATE diff --git a/res/qml/ComboBox.qml b/res/qml/ComboBox.qml index b1f36f5be5ef..ecf32cf871b7 100644 --- a/res/qml/ComboBox.qml +++ b/res/qml/ComboBox.qml @@ -1,6 +1,8 @@ import "." as Skin import QtQuick 2.12 import QtQuick.Controls 2.12 +import QtQuick.Shapes +import Qt5Compat.GraphicalEffects import "Theme" ComboBox { @@ -8,6 +10,7 @@ ComboBox { property alias popupWidth: popup.width property bool clip: false + property int popupMaxItem: 3 background: Skin.EmbeddedBackground { } @@ -17,12 +20,14 @@ ComboBox { required property int index - width: parent.width highlighted: root.highlightedIndex === this.index text: root.textAt(this.index) + padding: 4 + verticalPadding: 8 contentItem: Text { text: itemDlgt.text + font: root.font color: Theme.deckTextColor elide: Text.ElideRight verticalAlignment: Text.AlignVCenter @@ -30,15 +35,16 @@ ComboBox { background: Rectangle { radius: 5 - border.width: itemDlgt.highlighted ? 1 : 0 - border.color: Theme.deckLineColor + border.width: 1 + border.color: itemDlgt.highlighted ? Theme.deckLineColor : "transparent" color: "transparent" } } + indicator.width: 20 + contentItem: Text { leftPadding: 5 - rightPadding: root.indicator.width + root.spacing text: root.displayText font: root.font color: Theme.deckTextColor @@ -49,21 +55,71 @@ ComboBox { popup: Popup { id: popup - y: root.height - width: root.width - implicitHeight: contentItem.implicitHeight + y: root.height/2 + width: root.width - root.indicator.width / 2 + x: root.indicator.width / 2 + height: root.indicator.implicitHeight*Math.min(root.popupMaxItem, root.count) + root.indicator.width + + padding: 0 + + contentItem: Item { + Item { + id: content + anchors.fill: parent + Shape { + id: arrow + anchors.top: parent.top + anchors.right: parent.right + anchors.rightMargin: 3 + width: root.indicator.width-4 + height: width + antialiasing: true + layer.enabled: true + layer.samples: 4 + ShapePath { + fillColor: Theme.embeddedBackgroundColor + strokeColor: Theme.deckBackgroundColor + strokeWidth: 2 + startX: arrow.width/2; startY: 0 + fillRule: ShapePath.WindingFill + capStyle: ShapePath.RoundCap + PathLine { x: root.indicator.width; y: root.indicator.width } + PathLine { x: 0; y: root.indicator.width } + PathLine { x: (root.indicator.width) / 2; y: 0 } + } + } + Skin.EmbeddedBackground { + anchors.topMargin: root.indicator.width-6 + anchors.fill: parent + ListView { + clip: true + + anchors.fill: parent - contentItem: ListView { - clip: true - implicitHeight: contentHeight - model: root.popup.visible ? root.delegateModel : null - currentIndex: root.highlightedIndex + bottomMargin: 0 + leftMargin: 0 + rightMargin: 0 + topMargin: 0 - ScrollIndicator.vertical: ScrollIndicator { + model: root.popup.visible ? root.delegateModel : null + currentIndex: root.highlightedIndex + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AlwaysOn + } + } + } + } + DropShadow { + anchors.fill: parent + horizontalOffset: 0 + verticalOffset: 0 + radius: 8.0 + color: "#000000" + source: content } } - background: Skin.EmbeddedBackground { - } + background: Item {} } } diff --git a/res/qml/ControlSlider.qml b/res/qml/ControlSlider.qml index 0afd7021c0d4..82d2709e60bc 100644 --- a/res/qml/ControlSlider.qml +++ b/res/qml/ControlSlider.qml @@ -2,15 +2,19 @@ import "." as Skin import Mixxx 1.0 as Mixxx import QtQuick 2.12 -Skin.Slider { - property alias group: control.group - property alias key: control.key +Skin.Fader { + id: root + + required property string group + required property string key value: control.parameter onMoved: control.parameter = value Mixxx.ControlProxy { id: control + group: root.group + key: root.key } TapHandler { diff --git a/res/qml/Fader.qml b/res/qml/Fader.qml new file mode 100644 index 000000000000..bcae15fdde9b --- /dev/null +++ b/res/qml/Fader.qml @@ -0,0 +1,49 @@ +import Mixxx.Controls 1.0 as MixxxControls +import Qt5Compat.GraphicalEffects +import QtQuick 2.12 +import "Theme" + +MixxxControls.Slider { + id: root + + property alias fg: handleImage.source + property alias bg: backgroundImage.source + + bar: true + barMargin: 10 + implicitWidth: backgroundImage.implicitWidth + implicitHeight: backgroundImage.implicitHeight + + Image { + id: handleImage + + visible: false + source: Theme.imgSliderHandle + fillMode: Image.PreserveAspectFit + } + + handle: Item { + id: handleItem + + width: handleImage.paintedWidth + height: handleImage.paintedHeight + x: root.horizontal ? (root.visualPosition * (root.width - width)) : ((root.width - width) / 2) + y: root.vertical ? (root.visualPosition * (root.height - height)) : ((root.height - height) / 2) + + DropShadow { + source: handleImage + width: parent.width + 5 + height: parent.height + 5 + radius: 5 + verticalOffset: 5 + color: "#80000000" + } + } + + background: Image { + id: backgroundImage + + anchors.fill: parent + anchors.margins: root.barMargin + } +} diff --git a/res/qml/FormButton.qml b/res/qml/FormButton.qml new file mode 100644 index 000000000000..ac6ac3ad6ce8 --- /dev/null +++ b/res/qml/FormButton.qml @@ -0,0 +1,175 @@ +import Qt5Compat.GraphicalEffects +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import "Theme" + +AbstractButton { + id: root + + property color normalColor: Theme.white + property color backgroundColor: "#3F3F3F" + property color activeColor: Theme.deckActiveColor + property color pressedColor: activeColor + property bool highlight: false + + implicitWidth: 98 + implicitHeight: 20 + states: [ + State { + name: "pressed" + when: root.pressed + + PropertyChanges { + backgroundImage.color: root.checked ? "#3a60be" : root.backgroundColor + } + + PropertyChanges { + label.color: root.pressedColor + } + + PropertyChanges { + bottomInnerEffect.color: '#353535' + } + + PropertyChanges { + topInnerEffect.color: '#353535' + } + + PropertyChanges { + labelGlow.visible: true + } + + }, + State { + name: "active" + when: (root.highlight || root.checked) && !root.pressed + + PropertyChanges { + backgroundImage.color: "#2D4EA1" + } + + PropertyChanges { + label.color: root.activeColor + } + + PropertyChanges { + bottomInnerEffect.color: '#353535' + } + + PropertyChanges { + topInnerEffect.color: '#353535' + } + + PropertyChanges { + labelGlow.visible: true + } + + }, + State { + name: "inactive" + when: !root.checked && !root.highlight && !root.pressed + + PropertyChanges { + label.color: root.normalColor + } + + PropertyChanges { + labelGlow.visible: false + } + } + ] + + background: Item { + anchors.fill: parent + + Rectangle { + id: backgroundImage + visible: false + + anchors.fill: parent + color: root.backgroundColor + radius: 4 + } + InnerShadow { + id: bottomInnerEffect + anchors.fill: parent + radius: 8 + samples: 16 + spread: 0.3 + horizontalOffset: -1 + verticalOffset: -1 + color: "transparent" + source: backgroundImage + } + InnerShadow { + id: topInnerEffect + anchors.fill: parent + radius: 8 + samples: 16 + spread: 0.3 + horizontalOffset: 1 + verticalOffset: 1 + color: "transparent" + source: bottomInnerEffect + } + + DropShadow { + id: dropEffect + anchors.fill: parent + horizontalOffset: 0 + verticalOffset: 0 + radius: 4.0 + color: "#0E0E0E" + source: topInnerEffect + } + } + + contentItem: Item { + anchors.fill: parent + + Glow { + id: labelGlow + + anchors.fill: parent + radius: 1 + spread: 0.1 + color: label.color + source: label + } + + Label { + id: label + + visible: root.text != null + + anchors.fill: parent + text: root.text + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.family: Theme.fontFamily + font.capitalization: Font.AllUppercase + font.bold: true + font.pixelSize: Theme.buttonFontPixelSize + color: root.normalColor + } + Image { + id: image + + height: icon.height + width: icon.width + anchors.centerIn: parent + + source: icon.source + fillMode: Image.PreserveAspectFit + asynchronous: true + visible: false + } + ColorOverlay { + anchors.fill: image + source: image + visible: icon.source != null + color: root.normalColor + antialiasing: true + } + } +} diff --git a/res/qml/OrientationToggleButton.qml b/res/qml/OrientationToggleButton.qml index 280cd3baf443..556c69fc2bec 100644 --- a/res/qml/OrientationToggleButton.qml +++ b/res/qml/OrientationToggleButton.qml @@ -13,7 +13,7 @@ Item { implicitWidth: 56 implicitHeight: 26 - Skin.Slider { + Skin.Fader { id: orientationSlider anchors.fill: parent @@ -28,7 +28,7 @@ Item { stepSize: 1 value: control.value orientation: Qt.Horizontal - snapMode: Slider.SnapOnRelease + snapMode: Fader.SnapOnRelease onMoved: { // The slider's `value` is not updated until after the move ended. const val = valueAt(visualPosition); diff --git a/res/qml/Settings/AudioConnection.qml b/res/qml/Settings/AudioConnection.qml new file mode 100644 index 000000000000..5dac3d13b7a5 --- /dev/null +++ b/res/qml/Settings/AudioConnection.qml @@ -0,0 +1,173 @@ +import QtQuick 2.12 +import QtQuick.Shapes +import "../Theme" + +Item { + id: root + + enum Flags { + AboutToDelete = 1, + CannotConnect = 2 + } + + required property var source + required property var router + property var sink: undefined + property var target: source.mapToItem(router, source.width/2, source.height/2) + + property bool system: false + property bool vertical: false + property bool existing: false + property int flags: 0 + + readonly property bool ready: !!source && !!sink + + visible: !source || !sink || source.visible && sink.visible + + z: 0 + + states: [ + State { + name: "warning" + when: root.flags + + PropertyChanges { + line.strokeColor: Theme.warningColor + } + + PropertyChanges { + root.z: 50 + } + }, + State { + name: "system" + when: root.system + + PropertyChanges { + line.strokeColor: Theme.darkGray2 + } + }, + State { + name: "existing" + when: root.existing + + PropertyChanges { + line.strokeColor: Theme.midGray + } + }, + State { + name: "setting" + when: root.sink === undefined + + PropertyChanges { + line.strokeColor: Theme.accentColor + } + + PropertyChanges { + root.z: 50 + } + }, + State { + name: "set" + when: root.sink != undefined && !root.existing + + PropertyChanges { + line.strokeColor: Theme.accentColor + } + } + ] + + property var sourcePosition: source.mapToItem(router, source.width/2, source.height/2) + property var sinkPosition: sink ? sink.mapToItem(router, sink.width/2, sink.height/2) : target + + onSinkPositionChanged: { + scale.xScale = sourcePosition.x > sinkPosition.x ? -1 : 1 + scale.yScale = sourcePosition.y > sinkPosition.y ? -1 : 1 + } + + onSourcePositionChanged: { + scale.xScale = sourcePosition.x > sinkPosition.x ? -1 : 1 + scale.yScale = sourcePosition.y > sinkPosition.y ? -1 : 1 + } + + x: sourcePosition.x + y: sourcePosition.y + width: Math.max(2, Math.abs(sourcePosition.x - sinkPosition.x)) + height: Math.max(2, Math.abs(sourcePosition.y - sinkPosition.y)) + + onSinkChanged: { + if (sink != null && source != null) { + // swap entities if the connection was made backward + if (source.type !== "source") { + let swap = root.source + root.source = root.sink + root.sink = swap + return; + } + target = null + if (sink.connections !== undefined) { + sink.connections.add(root) + } else { + sink.connection = root + } + if (source.connections !== undefined) { + source.connections.add(root) + } else { + source.connection = root + } + } + } + onSourceChanged: { + if (sink != null && source != null) { + // swap entities if the connection was made backward + if (source.type !== "source") { + let swap = root.source + root.source = root.sink + root.sink = swap + return; + } + target = null + if (sink.connections !== undefined) { + sink.connections.add(root) + } else { + sink.connection = root + } + if (source.connections !== undefined) { + source.connections.add(root) + } else { + source.connection = root + } + } + } + onTargetChanged: { + if (!source) + return + scale.xScale = sourcePosition.x > sinkPosition.x ? -1 : 1 + scale.yScale = sourcePosition.y > sinkPosition.y ? -1 : 1 + } + + Shape { + anchors.fill: parent + // anchors.centerIn: parent + antialiasing: true + layer.enabled: true + layer.samples: 16 + layer.textureMirroring: ShaderEffectSource.MirrorHorizontally + ShapePath { + id: line + strokeColor: Theme.midGray + strokeWidth: 2 + fillColor: "transparent" + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.BevelJoin + + startX: 1 + startY: 1 + PathQuad { x: root.width * 0.5; y: root.height * 0.5; controlX: root.width * (root.vertical ? 0 : 0.3); controlY : root.height * (root.vertical ? 0.3 : 0) } + PathQuad { x: root.width; y: root.height; controlX: root.width * (root.vertical ? 1 : 0.7); controlY : root.height * (root.vertical ? 0.7 : 1) } + } + } + transform: Scale { + id: scale + } +} diff --git a/res/qml/Settings/AudioEntity.qml b/res/qml/Settings/AudioEntity.qml new file mode 100644 index 000000000000..d797ba96ef13 --- /dev/null +++ b/res/qml/Settings/AudioEntity.qml @@ -0,0 +1,326 @@ +import QtQuick 2.12 +import QtQuick.Controls +import QtQuick.Layouts +import ".." as Skin +import "../Theme" + +Item { + id: root + + signal connect(var entity) + signal disconnect(var entity) + + signal scrolled() + signal gatewayReady(string address, Item node) + + required property string name + required property string group + property list gateways: [] + property bool advanced: false + + implicitHeight: 54 + 32 * gatewayRepeater.visibleChannels + width: 135 + z: 10 + + onGatewaysChanged: { + gatewayRepeater.visibleChannels = root.gateways.length + } + + property alias handleSource: handleSourceEdge + property alias handleSink: handleSinkEdge + + property var metaType: null + Rectangle { + id: content + radius: 15 + color: Theme.darkGray3 + anchors.fill: parent + anchors.margins: 8 + Column { + id: gatewayColumn + anchors.fill: parent + padding: 0 + spacing: 4 + + Item { + height: nameLabel.implicitHeight + 18 + width: parent.width + Label { + id: nameLabel + anchors.fill: parent + anchors.margins: 9 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignTop + text: name + color: Theme.white + elide: Text.ElideRight + font.pixelSize: 15 + fontSizeMode: Text.Fit + } + } + + Repeater { + id: gatewayRepeater + model: root.gateways + property int visibleChannels: root.gateways.length + Repeater { + id: node + + required property int index + readonly property string label: root.gateways[index].name + readonly property string address: root.gateways[index].address || root.gateways[index].name + readonly property var channels: root.gateways[index].channels || [0, 1] + readonly property var instances: root.gateways[index].instances || 1 + readonly property string type: root.gateways[index].type + readonly property bool advanced: root.gateways[index].advanced || false + readonly property bool required: !!root.gateways[index].required + + model: node.channels.length/2 * instances + + property list channelAssignation: [...Array(node.channels.length/2)].map((_, i) => i) + + function availableEdge() { + for (let i = 0; i < node.count; i++) { + let current = node.itemAt(i); + if (current.edgeItem.connection) continue; + return i; + } + } + function assignedEdges() { + let assignation = {} + for (let i = 0; i < node.count; i++) { + let current = node.itemAt(i); + if (current.edgeItem.connection) { + assignation[node.channelAssignation[i]] = current.edgeItem.connection + } + } + return assignation + } + property int connectionCount: 0 + Item { + id: channel + + required property int index + property alias edgeItem: edge + property bool counted: channel.index == 0 + + visible: (edgeItem.connection?.ready || index == node.connectionCount) && (!node.advanced || root.advanced) + onVisibleChanged: { + if (counted != channel.visible) + gatewayRepeater.visibleChannels += channel.visible ? 1 : -1 + counted = channel.visible + } + + width: parent.width + height: 28 + RowLayout { + anchors { + left: parent.left + right: parent.right + leftMargin: 15 + rightMargin: 15 + } + id: inputLabel + Label { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: 28 + verticalAlignment: Text.AlignVCenter + text: node.instances == 1 ? label : `${label} #${index+1}` + color: Theme.white + elide: Text.ElideRight + font.pixelSize: 10 + fontSizeMode: Text.Fit + } + // Item { + // Layout.fillWidth: true + // } + Skin.ComboBox { + Layout.minimumWidth: implicitWidth + id: channelSelector + property int previousIndex: node.channelAssignation[channel.index] ?? 0 + visible: node.count > 1 && node.channels.length > 2 + spacing: 2 + clip: true + + font.pixelSize: 12 + model: { + return [...Array(node.channels.length/2)].map((e, i) => `Ch ${i * 2 + node.channels[0] + 1} - ${i * 2 + node.channels[0] + 2}`); + } + currentIndex: node.channelAssignation[channel.index] ?? 0 + onActivated: (activatedIndex) => { + let alreadyAssigned = node.channelAssignation.indexOf(activatedIndex) + node.channelAssignation[alreadyAssigned] = previousIndex + node.channelAssignation[channel.index] = activatedIndex + } + } + } + Rectangle { + id: edge + property var entity: root + property var advanced: node.advanced + property int instance: index / (node.channels.length/2) + property string type: node.type + property string group: root.group + property var address: node.address + + anchors.horizontalCenter: type == "source" ? parent.right : parent.left + anchors.verticalCenter: inputLabel.verticalCenter + + property var connection: null + property bool counted: false + property bool connecting: false + + onConnectionChanged: { + if (counted != !!edge.connection) + node.connectionCount += edge.connection ? 1 : -1 + counted = !!edge.connection + } + + function updateConnectionPosition() { + if (edge.connection && edge.connection.source == edge) { + edge.connection.sourcePosition = edge.mapToItem(edge.connection.router, edge.width/2, edge.height/2) + } else if (edge.connection && edge.connection.sink == edge) { + edge.connection.sinkPosition = edge.mapToItem(edge.connection.router, edge.width/2, edge.height/2) + } + } + + Connections { + target: root + function onScrolled() { + edge.updateConnectionPosition() + } + function onXChanged() { + edge.updateConnectionPosition() + } + function onYChanged() { + edge.updateConnectionPosition() + } + } + + Connections { + target: channel + function onHeightChanged() { + edge.updateConnectionPosition() + } + function onYChanged() { + edge.updateConnectionPosition() + } + } + + color: Theme.midGray + width: 10 + height: width + radius: width/2 + z: 100 + + states: [ + State { + name: "idle" + }, + State { + name: "warning" + when: (!edge.connection && node.required) || (edge.connection && edge.connection.state == "warning") + + PropertyChanges { + edge.width: 15 + edge.color: Theme.warningColor + } + }, + State { + name: "hidden" + when: edge.connection && !edge.connection.visible + + PropertyChanges { + channel.opacity: 0.5 + } + }, + State { + name: "setting" + when: edge.connection && !edge.connection.existing || edge.connecting + + PropertyChanges { + edge.width: 15 + edge.color: Theme.accentColor + } + } + ] + + MouseArea { + id: edgeMouseArea + hoverEnabled: edge.connection != null && edge.connection.visible + anchors.fill: parent + onPressed: { + if (edge.connection && edge.connection.flags & AudioConnection.Flags.AboutToDelete) { + root.disconnect(edge.connection) + } else if (edge.connection == null) { + root.connect(parent) + } + } + onEntered: { + if (edge.connection) { + edge.connection.flags |= AudioConnection.Flags.AboutToDelete + } + } + onExited: { + if (edge.connection) { + edge.connection.flags &= ~AudioConnection.Flags.AboutToDelete + } + } + } + } + } + Component.onCompleted: { + root.gatewayReady(address, node) + } + } + } + } + AudioEntityEdge { + id: handleSourceEdge + entity: root + type: "source" + + Connections { + target: root + + function onImplicitHeightChanged() { + handleSourceEdge.updateConnectionPosition() + } + function onXChanged() { + handleSourceEdge.updateConnectionPosition() + } + function onYChanged() { + handleSourceEdge.updateConnectionPosition() + } + } + + anchors.horizontalCenter: handleSourceEdge.vertical ? parent.horizontalCenter : parent.right + anchors.verticalCenter: handleSourceEdge.vertical ? parent.bottom : undefined + anchors.top: handleSourceEdge.vertical ? undefined :parent.top + anchors.topMargin: handleSourceEdge.vertical ? 0 :16 + } + AudioEntityEdge { + id: handleSinkEdge + entity: root + type: "sink" + + Connections { + target: root + + function onImplicitHeightChanged() { + handleSinkEdge.updateConnectionPosition() + } + function onXChanged() { + handleSinkEdge.updateConnectionPosition() + } + function onYChanged() { + handleSinkEdge.updateConnectionPosition() + } + } + + anchors.horizontalCenter: handleSinkEdge.vertical ? parent.horizontalCenter : parent.left + anchors.verticalCenter: handleSinkEdge.vertical ? parent.top : parent.verticalCenter + } + } +} diff --git a/res/qml/Settings/AudioEntityEdge.qml b/res/qml/Settings/AudioEntityEdge.qml new file mode 100644 index 000000000000..45b67f584b52 --- /dev/null +++ b/res/qml/Settings/AudioEntityEdge.qml @@ -0,0 +1,24 @@ +import QtQuick 2.12 + +Rectangle { + id: root + property bool vertical: false + required property var entity + required property string type + + property var connections: new Set() + + color: 'transparent' + width: 10 + height: 10 + + function updateConnectionPosition() { + for (let connection of connections) { + if (connection && connection.source == root) { + connection.sourcePosition = root.mapToItem(connection.router, root.width/2, root.height/2) + } else if (connection && connection.sink == root) { + connection.sinkPosition = root.mapToItem(connection.router, root.width/2, root.height/2) + } + } + } +} diff --git a/res/qml/Settings/AudioRouter.qml b/res/qml/Settings/AudioRouter.qml new file mode 100644 index 000000000000..5b60bf879258 --- /dev/null +++ b/res/qml/Settings/AudioRouter.qml @@ -0,0 +1,797 @@ +import Mixxx 1.0 as Mixxx +import QtQuick 2 +import QtQuick.Controls +import QtQuick.Layouts +import "../Theme" + +Rectangle { + id: root + color: '#0E0E0E' + radius: 5 + clip: true + + enum Mode { + Simple, + Advanced, + Legacy + } + + property var connections: new Set() + property var selectedTab: "" + property var newConnection: null + readonly property var mode: modeChoice.selected == "simple" ? AudioRouter.Mode.Simple : modeChoice.selected == "legacy" ? AudioRouter.Mode.Legacy : AudioRouter.Mode.Advanced + + property int hiddenConnections: 2 + + onModeChanged: { + updateHiddenConnectionCount() + } + + property var manager: Mixxx.SoundManager + + property Component connectionEdge: Qt.createComponent("AudioConnection.qml") + + property var inputDevices: new Object() + property var outputDevices: new Object() + + property var system: new Object() + property bool hasChanges: false + + property alias multiSoundcard: multiSoundcardChoice + + property var inputs: new Object() + property var outputs: new Object() + + function updateHiddenConnectionCount() { + root.hiddenConnections = 0 + if (root.mode == AudioRouter.Mode.Simple) { + for (let connection of root.connections) { + if (connection.source?.advanced || connection.sink?.advanced) { + root.hiddenConnections += 1 + } + } + } + } + + // FriendlyName aims to parse the raw device name extract it in such a way that it gets grouped within the UI + // Note that naming structures changes across API and devices. This function is experimental and aims to be extended with more logic + // If not extraction works, it will fallback to return the device name as "card name" (a.k.a a group on the router) and a single channel names "Default" + function friendlyName(api, rawName) { + // "api" value can be found in https://github.com/PortAudio/portaudio + switch (api) { + // TODO unimplemented, need some data or platform to test with + // case "JACK Audio Connection Kit": + // case "OSS": + // case "iOS Audio": + // case "Core Audio": + // case "AudioIO": + // case "AudioScience HPI": + // case "PulseAudio": + // case "sndio": + + case "ALSA": { + const hwAlsa = / (\(hw:\d+,\d+\))/; + let components = rawName.split(':') + let cardName = components.shift().trim() + let deviceName = components.length > 0 ? components.join(":").trim().replace(hwAlsa, "") : "Default" + return [cardName, deviceName] + } + case "MME": { + // API truncates the name to 31 chars + if (rawName.length === 31 && rawName.substr(-1) !== ')') { + const match = /(.+) \((.*)/.exec(rawName) + if (match) { + const [_, deviceName, cardName, ..._loopback] = match + return [cardName, deviceName] + } + break + } + } + case "ASIO": + case "Windows DirectSound": + case "Windows WASAPI": { + const match = /(.+) \((.*)\)( \[Loopback\])?/.exec(rawName) + if (match) { + const [_, deviceName, cardName, ..._loopback] = match + return [cardName, deviceName] + } + break + } + case "Windows WDM-KS": { + const match = /(.+) \((.*)\)( \[Loopback\])?/.exec(rawName.replace(/\r?\n/g, '')) + if (match) { + let [_, deviceName, cardName, ..._loopback] = match + if (cardName.startsWith("@System32\\drivers")) { + let components = cardName.split(";") + cardName = components[components.length - 1] + } + return [cardName, deviceName] + } + break + } + } + + return [rawName, ""] + } + + function generateDeviceList(api, devices, existing) { + console.log(`generating device list for: ${JSON.stringify(devices)}`) + let cards = {} + // cardName -> deviceName -> duplicateCount + let deviceNameDuplicate = {} + for (let device of devices) { + let [cardName, deviceName] = root.friendlyName(api, device.displayName) + cardName = !cardName ? "Unnamed card" : cardName + deviceName = !deviceName ? "Default" : deviceName + console.log(`cardName=${cardName},deviceName=${deviceName}`) + if (cards[cardName] === undefined) { + cards[cardName] = { + gateways: { + [deviceName]: { + name: deviceName, + node: existing[cardName]?.gateways?.[deviceName]?.node ?? null, + device: device, + channels: device.channelCount + } + }, + channelCount: device.channelCount, + } + continue + } + console.log(`cards=${cards[cardName]}`) + + // If the group (card name) has already a "Default" channel, we add the new channel as "Default #N" for better UX + if (cards[cardName].gateways[deviceName] !== undefined) { + if (deviceNameDuplicate[cardName] === undefined) { + deviceNameDuplicate[cardName] = { + [deviceName]: 1 + } + } else { + deviceNameDuplicate[cardName][deviceName] = (deviceNameDuplicate[cardName][deviceName] ?? 0) + 1 + } + deviceName = `${deviceName} #${deviceNameDuplicate[cardName][deviceName] + 1}` + } + + cards[cardName].gateways[deviceName] = { + name: deviceName, + node: existing[cardName]?.gateways?.[deviceName]?.node ?? null, + device: device, + channels: device.channelCount + } + cards[cardName].channelCount += device.channelCount + } + return cards + } + + function update(api) { + console.log(`Using sound api: ${api} ${typeof api}`) + root.inputs = generateDeviceList(api, manager.availableInputDevices(api), root.inputs) + root.outputs = generateDeviceList(api, manager.availableOutputDevices(api), root.outputs) + root.loadConnections() + } + + function registerInputEdge(device, address, node) { + root.inputs[device].gateways[address].node = node + if (root.inputs[device].gateways[address].delayedConnections) { + addExistingConnection(node, root.inputs[device].gateways[address].delayedConnections) + root.inputs[device].gateways[address].delayedConnections = undefined + } + } + function registerOutputEdge(device, address, node) { + root.outputs[device].gateways[address].node = node + if (root.outputs[device].gateways[address].delayedConnections) { + addExistingConnection(node, root.outputs[device].gateways[address].delayedConnections) + root.outputs[device].gateways[address].delayedConnections = undefined + } + } + + readonly property list audioTypeMap: [ + {entity: "Mixer",channel: "Main"}, // Main, + {entity: "Mixer",channel: "PFL"}, // Headphones, + {entity: "Mixer",channel: "Booth"}, // Booth, + {entity: "Mixer",channel: "Bus"}, // Bus, + {entity: "Deck",channel: "Output"}, // Deck, + {entity: "Deck",channel: "Vinyl Control"}, // VinylControl, + {entity: "Mixer",channel: "Microphone"}, // Microphone, + {entity: "Mixer",channel: "Auxiliary"}, // Auxiliary, + {entity: "Record",channel: "Additional input"}, // RecordBroadcast, + ] + + function addExistingConnection(node, connections) { + if (!connections) return; + for (let connection of connections) { + let typeDef = audioTypeMap[connection.type] + let source = root.system[typeDef.entity].gateways[typeDef.channel][connection.index].edgeItem + let availableEdge = node.availableEdge() + if (!source || availableEdge === null) { + console.error("Unable to get the source and sink of the existing connection!", source, availableEdge) + continue; + } + let connectionItem = root.connectionEdge.createObject(root, { + "existing": true, + "router": root, + "source": source, + "sink": node.itemAt(availableEdge).edgeItem, + }) + root.connections.add(connectionItem) + node.channelAssignation[availableEdge] = connection.channelGroup / 2 + } + root.updateHiddenConnectionCount() + } + + function loadConnections() { + while (root.connections.size) { + let connection = root.connections.keys().next().value; + root.entityOnDisconnect(connection) + } + + for (let device of Object.keys(root.outputs)) { + for (let address of Object.keys(root.outputs[device].gateways)) { + let gateway = root.outputs[device].gateways[address] + let node = gateway.node + let connections = root.outputs[device].gateways[address].device.connections(Mixxx.SoundManager) + + if (!connections) continue; + if (node) { + addExistingConnection(node, connections) + } else if (connections.length) { + root.outputs[device].gateways[address].delayedConnections = connections + } + } + } + + for (let device of Object.keys(root.inputs)) { + for (let address of Object.keys(root.inputs[device].gateways)) { + let gateway = root.inputs[device].gateways[address] + let node = gateway.node + let connections = root.inputs[device].gateways[address].device.connections(Mixxx.SoundManager) + console.log("INPUT", gateway, node, connections) + + if (!connections) continue; + if (node) { + addExistingConnection(node, connections) + } else if (connections.length) { + root.inputs[device].gateways[address].delayedConnections = connections + } + } + } + + root.hasChanges = false + } + + MouseArea { + enabled: root.newConnection != null + anchors.fill: parent + hoverEnabled: true + preventStealing: true + onPositionChanged: (mouse) => { + if (root.newConnection) { + root.newConnection.target = Qt.point(mouse.x, mouse.y) + } + } + onExited: { + if (root.newConnection) { + root.newConnection.destroy(); + root.newConnection.source.connection = null + root.newConnection = null + } + } + onPressed: { + if (root.newConnection) { + root.newConnection.destroy(); + root.newConnection.source.connection = null + root.newConnection = null + } + } + } + + function entityOnConnect(edge) { + if (root.newConnection != null) { + if (edge.type == root.newConnection.source.type || edge.group == root.newConnection.source.group || edge.entity == root.newConnection.source.entity) { + root.newConnection.flags |= AudioConnection.Flags.CannotConnect + return; + } + root.newConnection.source.connecting = false + if (root.newConnection.source == edge) { + root.newConnection.destroy(); + } else { + root.newConnection.sink = edge + root.connections.add(root.newConnection) + root.hasChanges = true + } + root.newConnection = null + } else { + root.newConnection = connectionEdge.createObject(root, {"router": root, "source": edge}); + } + } + + function entityOnDisconnect(connection) { + var sink = connection.sink; + var source = connection.source; + root.connections.delete(connection) + if (connection.existing) + root.hasChanges = true + + if (source != null) { + if (source.connection !== undefined) { + source.connection = null + } else { + source.connections.delete(connection) + } + } + + if (sink != null) { + if (sink.connection !== undefined) { + sink.connection = null + } else { + sink.connections.delete(connection) + } + } + connection.destroy() + } + + RowLayout { + anchors.fill: parent + + ColumnLayout { + visible: root.mode == AudioRouter.Mode.Advanced + Layout.fillHeight: true + Layout.minimumWidth: 200 + Layout.maximumWidth: 220 + Text { + Layout.alignment: Qt.AlignHCenter + Layout.margins: 15 + text: "Inputs" + color: '#626262' + font.pixelSize: 14 + } + + ListView { + id: inputList + Layout.margins: 15 + Layout.fillHeight: true + Layout.fillWidth: true + model: Object.keys(root.inputs) + reuseItems: false + spacing: 15 + delegate: AudioEntity { + id: inputEntity + required property var modelData + width: ListView.view.width + + name: modelData + group: "external" + gateways: { + let channels = [] + let maxChannelPerInput = 4 / root.inputs[modelData].channelCount; + for (let item of Object.values(root.inputs[modelData].gateways)) { + let channel = 0 + for (; channel < item.channels && channel <= maxChannelPerInput; channel += 2) { + channels.push({ + name: item.name, + address: item.address, + channels: [channel, channel+1], + type: "source", + advanced: true + }); + } + if (channel < item.channels) { + let start = channels[channels.length-1].channels[0] + let channelPicker = [...Array(item.channels - start)] + channels[channels.length-1].channels = channelPicker.map((_, i) => start+i) + } + } + return channels; + } + advanced: root.mode == AudioRouter.Mode.Advanced + + onGatewayReady: (address, node) => { + root.registerInputEdge(modelData, address, node) + } + + onConnect: (point) => root.entityOnConnect(point) + onDisconnect: (point) => root.entityOnDisconnect(point) + Connections { + target: inputList + function onContentYChanged() { + inputEntity.scrolled() + } + function onXChanged() { + inputEntity.scrolled() + } + function onYChanged() { + inputEntity.scrolled() + } + function onWidthChanged() { + inputEntity.scrolled() + } + function onHeightChanged() { + inputEntity.scrolled() + } + } + } + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AlwaysOn + anchors.right: inputList.left + anchors.rightMargin: 6 + } + } + } + Rectangle { + visible: root.mode == AudioRouter.Mode.Advanced + Layout.fillHeight: true + Layout.preferredWidth: 1 + color: '#626262' + } + ColumnLayout { + id: mainCanvas + RowLayout { + Layout.topMargin: 6 + Layout.bottomMargin: 3 + Item { + Layout.fillWidth: true + } + RatioChoice { + id: modeChoice + options: [ + "simple", + !root.hiddenConnections ? "advanced" : "advanced (!)", + "legacy" + ] + onOptionsChanged: { + if (modeChoice.selected.startsWith("advanced")) { + modeChoice.selected = options[1] + } + } + tooltips: !root.hiddenConnections ? [] : [null, `${root.hiddenConnections} connection${root.hiddenConnections > 1 ? 's' : ''} hidden\nUse the advanced mode to view them`, null] + } + } + Item { + Layout.fillHeight: true + } + RowLayout { + Layout.bottomMargin: 3 + RatioChoice { + id: multiSoundcardChoice + normalizedWidth: false + options: [ + "experimental", + "default", + "disabled" + ] + tooltips: [ + "No delay", + "Long delay", + "Short delay" + ] + + onSelectedChanged: { + root.hasChanges = true + } + + Mixxx.SettingParameter { + label: "Multi-Soundcard Synchronization" + } + } + Text { + text: "Multi-Soundcard Synchronization" + color: Theme.white + font.pixelSize: 14 + } + Item { + Layout.fillWidth: true + } + } + } + Rectangle { + visible: root.mode != AudioRouter.Mode.Legacy + Layout.fillHeight: true + Layout.preferredWidth: 1 + color: '#626262' + } + + ColumnLayout { + visible: root.mode != AudioRouter.Mode.Legacy + Layout.fillHeight: true + Layout.minimumWidth: 200 + Layout.maximumWidth: 220 + Text { + Layout.alignment: Qt.AlignHCenter + Layout.margins: 15 + text: "Outputs" + color: '#626262' + font.pixelSize: 14 + } + + ListView { + id: outputList + Layout.margins: 15 + Layout.fillHeight: true + Layout.fillWidth: true + model: Object.keys(root.outputs) + reuseItems: false + spacing: 15 + cacheBuffer: Math.max(0, contentHeight) // Disable lazy loading to make sure all item are loaded and can be bounded to connection + delegate: AudioEntity { + id: outputEntity + required property var modelData + width: ListView.view.width + + name: modelData + group: "external" + gateways: { + let channels = [] + let maxChannelPerOutput = 4 / root.outputs[modelData].channelCount; + for (let item of Object.values(root.outputs[modelData].gateways)) { + let channel = 0 + for (; channel < item.channels && channel <= maxChannelPerOutput; channel += 2) { + channels.push({ + name: item.name, + address: item.address, + channels: [channel, channel+1], + type: "sink" + }); + } + if (channel < item.channels) { + let start = channels[channels.length-1].channels[0] + let channelPicker = [...Array(item.channels - start)] + channels[channels.length-1].channels = channelPicker.map((_, i) => start+i) + } + } + return channels; + } + + onGatewayReady: (address, node) => { + root.registerOutputEdge(modelData, address, node) + } + + onConnect: (point) => root.entityOnConnect(point) + onDisconnect: (point) => root.entityOnDisconnect(point) + Connections { + target: outputList + function onContentYChanged() { + outputEntity.scrolled() + } + function onXChanged() { + outputEntity.scrolled() + } + function onYChanged() { + outputEntity.scrolled() + } + function onWidthChanged() { + outputEntity.scrolled() + } + function onHeightChanged() { + outputEntity.scrolled() + } + } + } + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AlwaysOn + anchors.left: outputList.right + anchors.leftMargin: 6 + } + } + + Item { + Layout.fillHeight: true + } + } + } + + Repeater { + id: decks + model: 4 + AudioEntity { + visible: root.mode != AudioRouter.Mode.Legacy + required property int index + id: deck + x: root.width / (root.mode == AudioRouter.Mode.Advanced ? 4 : 5) + y: (root.height / 5) * (1 + index) - implicitHeight / 2 + + name: `Deck ${index+1}` + group: "internal" + gateways: [{ + name: "Output", + type: "source", + advanced: true + }, { + name: "Vinyl Control", + type: "sink", + advanced: true + } + ] + advanced: root.mode == AudioRouter.Mode.Advanced + + onConnect: (point) => root.entityOnConnect(point) + onDisconnect: (point) => root.entityOnDisconnect(point) + + onGatewayReady: (address, node) => { + if (!root.system["Deck"]) { + root.system["Deck"] = { + gateways: {} + } + } + if (!root.system["Deck"].gateways[address]) { + root.system["Deck"].gateways[address] = [] + } + root.system["Deck"].gateways[address][deck.index] = node.itemAt(0) + } + } + onItemAdded: (index, item) => { + deckConnections.items.push(item) + } + onItemRemoved: (index, item) => { + deckConnections.items.slice(deckConnections.items.indexOf(item), 1) + } + } + + AudioEntity { + visible: root.mode != AudioRouter.Mode.Legacy + id: mixer + + x: root.width / 2 + y: Math.max(root.height / 16 , root.height / (root.mode == AudioRouter.Mode.Advanced ? 3 : 2) - implicitHeight / 2) + + name: "Mixer" + group: "internal" + + advanced: root.mode == AudioRouter.Mode.Advanced + + gateways: [{ + name: "PFL", + type: "source" + }, { + name: "Main", + type: "source", + required: true + }, { + name: "Booth", + type: "source" + }, { + name: "Left Bus", + type: "source", + advanced: true + }, { + name: "Center Bus", + type: "source", + advanced: true + }, { + name: "Right Bus", + type: "source", + advanced: true + }, { + name: "Auxiliary", + type: "sink", + instances: 4, + advanced: true + }, { + name: "Microphone", + type: "sink", + instances: 4, + advanced: true + } + ] + + Mixxx.SettingParameter { + label: "PFL" + } + Mixxx.SettingParameter { + label: "Main" + } + Mixxx.SettingParameter { + label: "Booth" + } + Mixxx.SettingParameter { + label: "Mixer" + } + Mixxx.SettingParameter { + label: "Decks" + } + Mixxx.SettingParameter { + label: "Input" + } + Mixxx.SettingParameter { + label: "Output" + } + Mixxx.SettingParameter { + label: "Broadcast" + } + + handleSource.vertical: mixer.height < root.height*0.75 + + onConnect: (point) => root.entityOnConnect(point) + onDisconnect: (point) => root.entityOnDisconnect(point) + + onGatewayReady: (address, node) => { + if (!root.system["Mixer"]) { + root.system["Mixer"] = { + gateways: {} + } + } + let nodeIdxOffset = 0 + if (address.endsWith(" Bus")) { + nodeIdxOffset = address.startsWith("Left ") ? 0 : address.startsWith("Right ") ? 2 : 1 + address = "Bus" + } + console.log("register", address, nodeIdxOffset) + if (!root.system["Mixer"].gateways[address]) { + root.system["Mixer"].gateways[address] = [] + } + for (let nodeIdx = 0; nodeIdx < node.count; nodeIdx++) { + root.system["Mixer"].gateways[address][nodeIdxOffset + nodeIdx] = node.itemAt(nodeIdx) + } + } + } + Repeater { + id: deckConnections + property list items: [] + model: items + AudioConnection { + visible: root.mode != AudioRouter.Mode.Legacy + required property var modelData + router: root + source: modelData.handleSource + sink: mixer.handleSink + system: true + } + } + AudioEntity { + id: record + visible: root.mode == AudioRouter.Mode.Advanced + + x: root.width / 5 * 3 + y: root.height / 5 * 4 - implicitHeight / 2 + + name: "Record/Broadcast" + group: "internal" + metaType: "sink" + + handleSink.vertical: true + + gateways: [{ + name: "Alternative input", + type: "sink" + } + ] + property var alternativeConnection: null + readonly property bool hasAlternativeConnection: alternativeConnection && alternativeConnection.ready + + onConnect: (point) => { + if (root.newConnection != null) { + record.alternativeConnection = root.newConnection + } + root.entityOnConnect(point) + if (root.newConnection != null) { + record.alternativeConnection = root.newConnection + } + } + onDisconnect: (point) => { + record.alternativeConnection = null + root.entityOnDisconnect(point) + } + + onGatewayReady: (address, node) => { + if (!root.system["Record"]) { + root.system["Record"] = { + gateways: {} + } + } + if (!root.system["Record"].gateways[address]) { + root.system["Record"].gateways[address] = [] + } + node = node.itemAt(0) + root.system["Record"].gateways[address][node.index] = node + } + } + + AudioConnection { + visible: root.mode == AudioRouter.Mode.Advanced && !record.hasAlternativeConnection + + router: root + source: mixer.handleSource + sink: record.handleSink + system: true + vertical: true + } +} diff --git a/res/qml/Settings/RatioChoice.qml b/res/qml/Settings/RatioChoice.qml new file mode 100644 index 000000000000..137e24cf7bc1 --- /dev/null +++ b/res/qml/Settings/RatioChoice.qml @@ -0,0 +1,309 @@ +import QtQuick 2.12 +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Shapes +import Qt5Compat.GraphicalEffects +import "../Theme" +import ".." as Skin + +Item { + id: root + required property list options + property list tooltips: [] + property string selected: options.length ? options[0] : null + property real spacing: 9 + property real maxWidth: 0 + property bool normalizedWidth: true + + onTooltipsChanged: { + popup.close() + } + + FontMetrics { + id: fontMetrics + font.pixelSize: 14 + font.capitalization: Font.AllUppercase + } + + implicitHeight: (contentList.visible ? contentList.height : contentSpin.height) + dropRatio.radius * 2 + implicitWidth: (contentList.visible ? contentList.width : contentSpin.width) + dropRatio.radius * 2 + readonly property real cellSize: { + Math.max.apply(null, options.map((option) => fontMetrics.advanceWidth(option))) + root.spacing*2 + } + + Rectangle { + id: contentList + visible: root.maxWidth == 0 || root.maxWidth > root.cellSize * root.options.length + anchors.centerIn: parent + height: 24 + width: { + if (root.normalizedWidth) { + root.cellSize * root.options.length + root.spacing + } else { + options.reduce((acc, option) => acc + fontMetrics.advanceWidth(option) + root.spacing*2, 0) + root.spacing + } + } + color: '#2B2B2B' + radius: height / 2 + RowLayout { + anchors.fill: parent + Repeater { + model: options + Item { + required property int index + required property var modelData + width: root.normalizedWidth ? root.cellSize : fontMetrics.advanceWidth(modelData) + root.spacing*2 + height: contentList.height + Rectangle { + anchors.fill: parent + color: root.selected == modelData ? Theme.accentColor : 'transparent' + radius: height / 2 + id: contentOption + Text { + text: modelData + color: Theme.white + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font: fontMetrics.font + } + MouseArea { + anchors.fill: parent + hoverEnabled: !!root.tooltips[index] + onPressed: { + root.selected = modelData + } + + onEntered: { + if (!root.tooltips[index]) return; + popup.tooltip = root.tooltips[index] || "" + popup.x = Qt.binding(function() { return contentOption.mapToItem(root, 0, 0).x + contentOption.width / 2 - popup.width / 2; }) + popup.open() + } + onExited: { + popup.close() + } + } + } + InnerShadow { + visible: root.selected == modelData + id: bottomOptionInnerEffect + anchors.fill: parent + radius: 8 + samples: 32 + spread: 0.4 + horizontalOffset: -1 + verticalOffset: -1 + color: "#0E2A54" + source: contentOption + } + InnerShadow { + visible: root.selected == modelData + id: topOptionInnerEffect + anchors.fill: parent + radius: 8 + samples: 32 + spread: 0.4 + horizontalOffset: 1 + verticalOffset: 1 + color: "#0E2A54" + source: bottomOptionInnerEffect + } + } + } + } + } + SpinBox { + id: contentSpin + visible: !contentList.visible + anchors.centerIn: parent + from: 0 + padding: 0 + spacing: root.spacing + to: root.options.length - 1 + font: fontMetrics.font + value: root.options.indexOf(root.selected) + + property real textWidth: fontMetrics.advanceWidth(root.options.reduce((accumulator, currentValue) => accumulator.length > currentValue.length ? accumulator : currentValue, "")) + + contentItem: Item { + width: contentSpin.textWidth + 2 * contentSpin.spacing + Rectangle { + id: content + anchors.fill: parent + color: Theme.accentColor + radius: height / 2 + Text { + id: textLabel + anchors.fill: parent + text: contentSpin.textFromValue(contentSpin.value, contentSpin.locale) ?? "" + color: Theme.white + font: contentSpin.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + InnerShadow { + id: bottomInnerEffect + anchors.fill: parent + radius: 8 + samples: 32 + spread: 0.4 + horizontalOffset: -1 + verticalOffset: -1 + color: "#0E2A54" + source: content + } + InnerShadow { + id: topInnerEffect + anchors.fill: parent + radius: 8 + samples: 32 + spread: 0.4 + horizontalOffset: 1 + verticalOffset: 1 + color: "#0E2A54" + source: bottomInnerEffect + } + } + + component Indicator: Rectangle { + required property string text + height: implicitHeight + implicitWidth: 24 + implicitHeight: 24 + radius: parent.height / 2 + color: '#2B2B2B' + border.width: 0 + + Text { + text: parent.text + font.pixelSize: contentSpin.font.pixelSize + color: Theme.white + anchors.fill: parent + fontSizeMode: Text.Fit + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + up.indicator: Indicator { + x: contentSpin.mirrored ? 0 : parent.width - width + text: ">" + } + + down.indicator: Indicator { + x: contentSpin.mirrored ? parent.width - width : 0 + text: "<" + } + + background: Rectangle { + implicitWidth: contentSpin.textWidth + 2 * contentSpin.spacing + 48 + radius: parent.height / 2 + color: '#2B2B2B' + } + + textFromValue: function(value) { + return root.options[value]; + } + + valueFromText: function(text) { + for (var i = 0; i < root.options.length; ++i) { + if (root.options[i].toLowerCase().indexOf(text.toLowerCase()) === 0) + return i + } + return contentSpin.value + } + + onValueChanged: { + root.selected = contentSpin.textFromValue(value) ?? "" + popup.tooltip = root.tooltips[contentSpin.value] ?? "" + popup.x = contentSpin.width / 2 - popup.width / 2 + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onEntered: { + if (!root.tooltips[contentSpin.value]) return; + popup.x = contentSpin.width / 2 - popup.width / 2 + popup.open() + } + onExited: { + popup.close() + } + onPressed: { + mouse.accepted = false + } + } + } + DropShadow { + id: dropRatio + anchors.margins: dropRatio.radius + anchors.fill: root + horizontalOffset: 0 + verticalOffset: 0 + radius: 4.0 + color: "#80000000" + source: contentList.visible ? contentList : contentSpin + } + Popup { + id: popup + y: root.height + x: 0 + width: Math.max(tooltip.implicitWidth* 1.5, 50) + height: tooltip.implicitHeight + 15 + closePolicy: Popup.NoAutoClose + + property string tooltip: "" + + padding: 0 + + contentItem: Item { + Item { + id: contentPopup + anchors.fill: parent + Shape { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: 20 + height: width + antialiasing: true + layer.enabled: true + layer.samples: 4 + ShapePath { + fillColor: Theme.embeddedBackgroundColor + strokeColor: Theme.deckBackgroundColor + strokeWidth: 2 + startX: 10; startY: 0 + fillRule: ShapePath.WindingFill + capStyle: ShapePath.RoundCap + PathLine { x: 20; y: 10 } + PathLine { x: 0; y: 10 } + PathLine { x: 10; y: 0 } + } + } + Skin.EmbeddedBackground { + anchors.topMargin: 10 + anchors.fill: parent + Text { + anchors.centerIn: parent + id: tooltip + color: Theme.white + text: popup.tooltip + } + } + } + DropShadow { + anchors.fill: parent + horizontalOffset: 0 + verticalOffset: 0 + radius: 8.0 + color: "#000000" + source: contentPopup + } + } + + background: Item {} + } +} diff --git a/res/qml/Settings/SoundHardware.qml b/res/qml/Settings/SoundHardware.qml index b1b6f70c9de2..16ca0c5d56c9 100644 --- a/res/qml/Settings/SoundHardware.qml +++ b/res/qml/Settings/SoundHardware.qml @@ -1,5 +1,8 @@ import QtQuick +import QtQuick.Layouts import Mixxx 1.0 as Mixxx +import ".." as Skin +import "../Theme" Category { id: root @@ -7,58 +10,630 @@ Category { label: "Sound hardware" tabs: ["engine", "delays", "stats"] - Mixxx.SettingGroup { - label: "Engine" - visible: root.selectedIndex == 0 + property bool hasChanges: router.hasChanges + property bool committing: false - onActivated: { - root.selectedIndex = 0; - } + Mixxx.ControlProxy { + id: mainEnabled + group: "[Master]" + key: "enabled" + } + Mixxx.ControlProxy { + id: headEnabled + group: "[Master]" + key: "headEnabled" + } + Mixxx.ControlProxy { + id: boothEnabled + group: "[Master]" + key: "booth_enabled" + } + Mixxx.ControlProxy { + id: mainDelay + group: "[Master]" + key: "delay" + } + Mixxx.ControlProxy { + id: headDelay + group: "[Master]" + key: "headDelay" + } + Mixxx.ControlProxy { + id: boothDelay + group: "[Master]" + key: "boothDelay" + } + Mixxx.ControlProxy { + id: monoMix + group: "[Master]" + key: "mono_mixdown" + } + Mixxx.ControlProxy { + id: micMonitorMode + group: "[Master]" + key: "talkover_mix" + } - Mixxx.SettingParameter { - label: "A cyan square" + function save() { + const manager = Mixxx.SoundManager; + mainEnabled.value = mainMixEnabled.options.indexOf(mainMixEnabled.selected) + monoMix.value = !mainOutputMode.options.indexOf(mainOutputMode.selected) + manager.setForceNetworkClock(soundClock.options[1] == soundClock.selected) + manager.setSampleRate(parseInt(sampleRate.selected)) + manager.setAudioBufferSizeIndex(audioBuffer.currentIndex + 1) + micMonitorMode.value = microphoneMonitorMode.currentIndex + manager.setAPI(soundApi.selected) + manager.setKeylockEngine(keylock.options.indexOf(keylock.selected)) - Rectangle { - color: 'cyan' - height: 20 - width: 20 + // Router + manager.setSyncBuffers(router.multiSoundcard.options.indexOf(router.multiSoundcard.selected)) + + let connectionsHandler = (connections, device) => { + for (let channel of Object.keys(connections)) { + let connection = connections[channel] + let type; + let index = 0; + let isOutput = true + if (connection.source.entity.name == "Mixer") { + switch (connection.source.address) { + case "Main": + type = 0; + break; + case "PFL": + type = 1; + break; + case "Booth": + type = 2; + break; + case "Left Bus": + case "Center Bus": + case "Right Bus": + index = connection.source.address.startsWith("Left") ? 0 : connection.source.address.startsWith("Right") ? 2 : 1; + type = 3; + break; + default: + console.error(`unsupported address: ${connection.source.address}`) + continue; + } + } else if (connection.sink.entity.name == "Mixer") { + isOutput = false; + type = connection.sink.address == "Auxiliary" ? 7 : 6; + index = connection.sink.instance; + } else if (connection.source.entity.name.startsWith("Deck ") && connection.source.address == "Output") { + type = 4; + index = parseInt(connection.source.entity.name.split(' ')[1])-1; + } else if (connection.sink.entity.name.startsWith("Deck ")) { + isOutput = false; + type = connection.source.address == "Output" ? 4 : 5; + index = parseInt(connection.sink.entity.name.split(' ')[1])-1; + } else if (connection.sink.entity.name == "Microphone") { + isOutput = false; + type = 6 + index = connection.sink.instance; + } else if (connection.sink.entity.name == "Auxiliary") { + isOutput = false; + type = 7; + index = connection.sink.instance; + } else if (connection.sink.entity.name == "RecordBroadcast") { + isOutput = false; + type = 8; + index = connection.sink.instance; + } else { + console.error(`unsupported entity: ${connection.source.entity.name} ${connection.sink.entity.name}`) + continue; + } + console.log(isOutput ? "addOutput" : "addInput", device, type, channel * 2, index) + if (isOutput) { + manager.addOutput(device, type, channel * 2, index) + } else { + manager.addInput(device, type, channel * 2, index) + } + } + }; + manager.clearOutputs() + for (let device of Object.keys(router.outputs)) { + for (let address of Object.keys(router.outputs[device].gateways)) { + let gateway = router.outputs[device].gateways[address] + let connections = gateway.node && gateway.node.assignedEdges ? gateway.node.assignedEdges() : {}; + connectionsHandler(connections, gateway.device) + } + } + manager.clearInputs() + for (let device of Object.keys(router.inputs)) { + for (let address of Object.keys(router.inputs[device].gateways)) { + let gateway = router.inputs[device].gateways[address] + let connections = gateway.node && gateway.node.assignedEdges ? gateway.node.assignedEdges() : {}; + connectionsHandler(connections, gateway.device) } } + + mainDelay.value = mainDelaySlider.value + boothDelay.value = boothDelaySlider.value + headDelay.value = headphoneDelaySlider.value + + root.committing = true + manager.commit() } - Mixxx.SettingGroup { - label: "Delays" - visible: root.selectedIndex == 1 - onActivated: { - root.selectedIndex = 1; - } + function load() { + const manager = Mixxx.SoundManager; + mainMixEnabled.selected = mainMixEnabled.options[mainEnabled.value ? 0 : 1 ] + mainOutputMode.selected = mainOutputMode.options[monoMix.value ? 0 : 1 ] + soundClock.selected = soundClock.options[manager.getForceNetworkClock() ? 1 : 0 ] + sampleRate.update(manager.getAPI()) + sampleRate.selected = qsTr("%1 Hz").arg(manager.getSampleRate()) + audioBuffer.currentIndex = manager.getAudioBufferSizeIndex() - 1 + microphoneMonitorMode.enabled = manager.hasMicInputs() + microphoneMonitorMode.currentIndex = micMonitorMode.value + soundApi.options = manager.getHostAPIList() + soundApi.selected = manager.getAPI() + keylock.update() + keylock.selected = keylock.options[manager.getKeylockEngine()] - Mixxx.SettingParameter { - label: "A magenta square" + // Router + router.multiSoundcard.selected = router.multiSoundcard.options[manager.getSyncBuffers()] + router.update(manager.getAPI()) - Rectangle { - color: 'magenta' - height: 20 - width: 20 - } - } + //Delays + mainDelayLabel.enabled = mainEnabled.value + mainDelaySlider.enabled = mainEnabled.value + mainDelaySlider.value = mainDelay.value + boothDelayLabel.enabled = boothEnabled.value + boothDelaySlider.enabled = boothEnabled.value + boothDelaySlider.value = boothDelay.value + headphoneDelayLabel.enabled = headEnabled.value + headphoneDelaySlider.enabled = headEnabled.value + headphoneDelaySlider.value = headDelay.value + + root.hasChanges = Qt.binding(function() { return router.hasChanges; }); } - Mixxx.SettingGroup { - label: "Stats" - visible: root.selectedIndex == 2 - onActivated: { - root.selectedIndex = 2; - } + Component.onCompleted: { + load() + } + + ColumnLayout { + anchors.fill: parent + + Item { + id: tabSection + Layout.fillWidth: true + Layout.preferredHeight: root.selectedIndex == 0 ? engine.height : delays.height + Mixxx.SettingGroup { + label: "Engine" + visible: root.selectedIndex == 0 + onActivated: { + root.selectedIndex = 0 + } + anchors.left: parent.left + anchors.right: parent.right + RowLayout { + id: engine + anchors.left: parent.left + anchors.right: parent.right + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + RowLayout { + Text { + Mixxx.SettingParameter { + label: "Main Mix" + } + Layout.fillWidth: true + text: "Main Mix" + color: Theme.white + font.pixelSize: 14 + } + RatioChoice { + id: mainMixEnabled + options: [ + "on", + "off" + ] + selected: options[mainEnabled.value ? 0 : 1 ] + onSelectedChanged: { + root.hasChanges = true + } + } + } + + RowLayout { + Text { + Mixxx.SettingParameter { + label: "Main Output Mode" + } + Layout.fillWidth: true + text: "Main Output Mode" + color: Theme.white + font.pixelSize: 14 + } + RatioChoice { + id: mainOutputMode + options: [ + "mono", + "stereo" + ] + selected: options[monoMix.value ? 0 : 1 ] + onSelectedChanged: { + root.hasChanges = true + } + } + } + + RowLayout { + Text { + Mixxx.SettingParameter { + label: "Sound Clock" + } + Layout.fillWidth: true + text: "Sound Clock" + color: Theme.white + font.pixelSize: 14 + } + RatioChoice { + id: soundClock + options: [ + "soundcard", + "network" + ] + onSelectedChanged: { + root.hasChanges = true + } + } + } + + RowLayout { + Text { + Layout.fillWidth: true + text: "Keylock engine" + color: Theme.white + font.pixelSize: 14 + } + RatioChoice { + id: keylock + normalizedWidth: false + maxWidth: tabSection.width * 0.4 + options: [] + tooltips: [] - Mixxx.SettingParameter { - label: "A white square" + function update() { + let options = [] + let tooltips = [] + for (let engine of Mixxx.SoundManager.getKeylockEngines()) { + switch (engine) { + case 0: + options.push(qsTr("Soundtouch")) + tooltips.push(qsTr("Faster")) + break + case 1: + options.push(qsTr("Rubberband")) + tooltips.push(qsTr("Better")) + break + case 2: + options.push(qsTr("Rubberband R3")) + tooltips.push(qsTr("Near-hi-fi quality")) + break + } + } + keylock.options = options + keylock.tooltips = tooltips + } + onSelectedChanged: { + root.hasChanges = true + } + + Mixxx.SettingParameter { + label: "Keylock engine" + } + } + } + } + Item { + Layout.preferredWidth: 70 + } + ColumnLayout { + Layout.alignment: Qt.AlignTop + RowLayout { + Text { + Layout.fillWidth: true + text: "Sound API" + color: Theme.white + font.pixelSize: 14 + } + RatioChoice { + id: soundApi + maxWidth: tabSection.width * 0.4 + options: [] + + onSelectedChanged: { + root.hasChanges = true + router.update(soundApi.selected) + sampleRate.update(soundApi.selected) + } + + Mixxx.SettingParameter { + label: "Sound API" + } + } + } + RowLayout { + Text { + Mixxx.SettingParameter { + label: "Sample Rate" + } + Layout.fillWidth: true + text: "Sample Rate" + color: Theme.white + font.pixelSize: 14 + } + RatioChoice { + id: sampleRate + Layout.minimumWidth: sampleRate.implicitWidth + options: [] + function update(api) { + let data = [] + for (let sampleRate of Mixxx.SoundManager.getSampleRates(api)) { + data.push(qsTr("%1 Hz").arg(sampleRate)); + } + sampleRate.options = data + } + onSelectedChanged: { + root.hasChanges = true + } + } + } + + Connections { + target: sampleRate + function onSelectedChanged() { + let sampleRateValue = parseInt(sampleRate.selected) + audioBuffer.update(sampleRateValue) + } + } + + RowLayout { + Text { + Mixxx.SettingParameter { + label: "Audio Buffer" + } + Layout.fillWidth: true + text: "Audio Buffer" + color: Theme.white + font.pixelSize: 14 + } + Skin.ComboBox { + id: audioBuffer + spacing: 2 + clip: true + + font.pixelSize: 12 + + function update(sampleRate) { + let data = [] + let framesPerBuffer = 1; + for (; framesPerBuffer / sampleRate * 1000 < 1.0; framesPerBuffer *= 2) { + } + for (let i = 0; i < 7; i++) { + const latency = framesPerBuffer / sampleRate * 1000; + // i + 1 in the next line is a latency index as described in SSConfig + data.push(qsTr("%1 ms").arg(latency.toFixed(1))); + framesPerBuffer *= 2 + } + let currentIndex = audioBuffer.currentIndex + audioBuffer.model = data + audioBuffer.currentIndex = currentIndex + } + onCurrentIndexChanged: { + root.hasChanges = true + } + } + } + + RowLayout { + Text { + Mixxx.SettingParameter { + label: "Microphone Monitor Mode" + } + Layout.fillWidth: true + text: "Microphone Monitor Mode" + color: Theme.white + opacity: Mixxx.SoundManager.hasMicInputs() ? 1.0 : 0.5 + font.pixelSize: 14 + } + Skin.ComboBox { + id: microphoneMonitorMode + spacing: 2 + clip: true + opacity: enabled ? 1.0 : 0.5 + + font.pixelSize: 12 + model: [ + "Main output only", + "Main and booth outputs", + "Direct monitor (recording and broadcasting only)" + ] + onCurrentIndexChanged: { + root.hasChanges = true + } + } + } + } + } + } + + Mixxx.SettingGroup { + label: "Delays" + visible: root.selectedIndex == 1 + onActivated: { + root.selectedIndex = 1 + } + anchors.left: parent.left + anchors.right: parent.right + GridLayout { + id: delays + anchors.left: parent.left + anchors.right: parent.right + columns: 2 + rowSpacing: 0 + Text { + Mixxx.SettingParameter { + label: "Main Output" + } + id: mainDelayLabel + Layout.fillWidth: true + text: "Main Output" + color: Theme.white + opacity: enabled ? 1 : 0.5 + font.pixelSize: 14 + } + Skin.Slider { + id: mainDelaySlider + Layout.fillWidth: true + markers: ["0ms", "100ms", "1s", "10s", null] + suffix: "ms" + slider.to: 1000 + + onValueChanged: { + root.hasChanges = true + } + } + Text { + Mixxx.SettingParameter { + label: "Booth Output" + } + id: boothDelayLabel + Layout.fillWidth: true + text: "Booth Output" + color: Theme.white + opacity: enabled ? 1 : 0.5 + enabled: boothEnabled.value + font.pixelSize: 14 + } + Skin.Slider { + id: boothDelaySlider + Layout.fillWidth: true + markers: ["0ms", "100ms", "1s", "10s", null] + suffix: "ms" + enabled: boothEnabled.value + value: boothDelay.value + slider.to: 1000 + + onValueChanged: { + root.hasChanges = true + } + } + Text { + Mixxx.SettingParameter { + label: "Headphone Output" + } + id: headphoneDelayLabel + Layout.fillWidth: true + text: "Headphone Output" + color: Theme.white + opacity: enabled ? 1 : 0.5 + enabled: headEnabled.value + font.pixelSize: 14 + } + Skin.Slider { + id: headphoneDelaySlider + Layout.fillWidth: true + markers: ["0ms", "100ms", "1s", "10s", null] + suffix: "ms" + enabled: headEnabled.value + value: headDelay.value + slider.to: 1000 + + onValueChanged: { + root.hasChanges = true + } + } + } + } + + Mixxx.SettingGroup { + label: "Stats" + visible: root.selectedIndex == 2 + onActivated: { + root.selectedIndex = 2 + } + Mixxx.SettingParameter { + label: "A white square" + Rectangle { + width: 20 + height: 20 + color: 'white' + } + } + } + } + Mixxx.SettingGroup { + label: "Router" + Layout.fillWidth: true + Layout.fillHeight: true + AudioRouter { + id: router + anchors.fill: parent + } Rectangle { - color: 'white' - height: 20 - width: 20 + anchors.fill: parent + visible: root.committing + color: Qt.alpha('grey', 0.3) + MouseArea { + anchors.fill: parent + preventStealing: true + hoverEnabled: true + + onWheel: (mouse)=> { + mouse.accepted = true + } + } + } + } + RowLayout { + Layout.topMargin: 4 + Skin.FormButton { + enabled: !root.committing + visible: root.hasChanges + text: "Cancel" + opacity: enabled ? 1.0 : 0.5 + backgroundColor: "#7D3B3B" + activeColor: "#999999" + onPressed: { + root.load() + } + } + Item { + Layout.fillWidth: true + } + Text { + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: 16 + id: errorMessage + text: "" + color: "#7D3B3B" + } + Skin.FormButton { + enabled: root.hasChanges && !root.committing + text: "Save" + opacity: enabled ? 1.0 : 0.5 + backgroundColor: root.hasChanges ? "#3a60be" : "#3F3F3F" + activeColor: "#999999" + onPressed: { + errorMessage.text = "" + root.save() + } + } + } + } + Connections { + target: Mixxx.SoundManager + function onCommitted(error) { + root.committing = false + if (error) { + errorMessage.text = error } + root.load() } } } diff --git a/res/qml/Slider.qml b/res/qml/Slider.qml index bcae15fdde9b..ae9f828f73f0 100644 --- a/res/qml/Slider.qml +++ b/res/qml/Slider.qml @@ -1,49 +1,170 @@ -import Mixxx.Controls 1.0 as MixxxControls import Qt5Compat.GraphicalEffects import QtQuick 2.12 +import QtQuick.Controls +import QtQuick.Layouts import "Theme" -MixxxControls.Slider { +RowLayout { id: root + property list markers: ["", ""] - property alias fg: handleImage.source - property alias bg: backgroundImage.source + property alias suffix: textInputSection.suffix + property alias slider: control + property alias value: control.value - bar: true - barMargin: 10 - implicitWidth: backgroundImage.implicitWidth - implicitHeight: backgroundImage.implicitHeight + height: 30 - Image { - id: handleImage + Slider { + id: control - visible: false - source: Theme.imgSliderHandle - fillMode: Image.PreserveAspectFit - } - - handle: Item { - id: handleItem + Layout.fillWidth: true - width: handleImage.paintedWidth - height: handleImage.paintedHeight - x: root.horizontal ? (root.visualPosition * (root.width - width)) : ((root.width - width) / 2) - y: root.vertical ? (root.visualPosition * (root.height - height)) : ((root.height - height) / 2) + background: Item { + x: control.leftPadding + 7 + implicitWidth: 200 + implicitHeight: 4 + width: control.availableWidth - 7 + height: control.availableHeight + Rectangle { + width: parent.width + height: 4 + radius: 2 + color: "#181818" + } + Repeater { + id: delegate + model: markers + anchors.fill: parent + anchors.leftMargin: 7 + Item { + required property int index + required property var modelData + x: parent.width * (index / (delegate.model.length - 1)) + y: -4 + height: control.availableHeight - DropShadow { - source: handleImage - width: parent.width + 5 - height: parent.height + 5 - radius: 5 - verticalOffset: 5 - color: "#80000000" + Rectangle { + id: mark + visible: modelData != null + anchors { + top: parent.top + } + width: 1 + height: 11 + color: Qt.alpha(Theme.white, 0.25) + } + Text { + id: label + visible: modelData != null + anchors { + top: mark.bottom + topMargin: 4 + horizontalCenter: mark.left + } + color: Qt.alpha(Theme.white, 0.25) + font.pixelSize: 9 + text: modelData ?? "" + } + } + } + } + handle: Item { + x: control.leftPadding + control.visualPosition * (control.availableWidth - width) + y: -5 + width: 14 + height: 14 + Rectangle { + id: handle + anchors.fill: parent + radius: 7 + color: Theme.accentColor + } + InnerShadow { + id: handleEffect1 + anchors.fill: parent + samples: 16 + horizontalOffset: 0 + verticalOffset: 0 + radius: 16.0 + color: "#0E2A54" + source: handle + } + DropShadow { + id: handleEffect2 + anchors.fill: parent + source: handleEffect1 + horizontalOffset: 0 + verticalOffset: 0 + radius: 12.0 + color: Qt.alpha(Theme.darkGray, 0.25) + } } } + FocusScope { + id: textInputSection + Layout.leftMargin: 17 + Layout.minimumWidth: fontMetrics.advanceWidth + 8 + Layout.preferredHeight: 30 + Layout.margins: 4 - background: Image { - id: backgroundImage + property string suffix: "" + visible: suffix.length > 0 - anchors.fill: parent - anchors.margins: root.barMargin + Rectangle { + id: backgroundInput + radius: 4 + color: Theme.darkGray2 + anchors.fill: parent + anchors.margins: 4 + } + DropShadow { + id: dropSetting + anchors.fill: parent + horizontalOffset: 0 + verticalOffset: 0 + radius: 4.0 + color: Theme.darkGray + source: backgroundInput + } + InnerShadow { + id: effect2 + anchors.fill: parent + source: dropSetting + spread: 0.2 + radius: 12 + samples: 24 + horizontalOffset: 0 + verticalOffset: 0 + color: "#353535" + } + Item { + anchors.fill: parent + anchors.margins: 4 + TextInput { + anchors.left: parent.left + anchors.right: inputField.left + anchors.margins: 3 + focus: true + color: Qt.alpha(acceptableInput ? Theme.white : Theme.warningColor, root.enabled ? 1 : 0.5) + onAccepted: { + control.value = parseInt(text) + } + text: Math.round(control.value) + horizontalAlignment: TextInput.AlignRight + validator: IntValidator {bottom: control.from; top: control.to} + } + Text { + id: inputField + anchors.right: parent.right + anchors.margins: textInputSection.suffix.length > 0 ? 10 : 0 + text: textInputSection.suffix + color: Qt.alpha(Theme.white, root.enabled ? 1 : 0.5) + TextMetrics { + id: fontMetrics + font.family: inputField.font.family + text: `${control.to} ${parent.text}` + } + } + } } } diff --git a/src/coreservices.cpp b/src/coreservices.cpp index 040b9dad15b9..a2ff74d8391f 100644 --- a/src/coreservices.cpp +++ b/src/coreservices.cpp @@ -43,6 +43,7 @@ #include "qml/qmleffectsmanagerproxy.h" #include "qml/qmllibraryproxy.h" #include "qml/qmlplayermanagerproxy.h" +#include "qml/qmlsoundmanagerproxy.h" #endif #include "soundio/soundmanager.h" #include "sources/soundsourceproxy.h" @@ -513,6 +514,7 @@ void CoreServices::initializeQMLSingletons() { mixxx::qml::QmlPlayerManagerProxy::registerPlayerManager(getPlayerManager()); mixxx::qml::QmlConfigProxy::registerUserSettings(getSettings()); mixxx::qml::QmlLibraryProxy::registerLibrary(getLibrary()); + mixxx::qml::QmlSoundManagerProxy::registerManager(getSoundManager()); ControllerScriptEngineBase::registerTrackCollectionManager(getTrackCollectionManager()); diff --git a/src/engine/enginebuffer.h b/src/engine/enginebuffer.h index f844235565c9..37b4387224a1 100644 --- a/src/engine/enginebuffer.h +++ b/src/engine/enginebuffer.h @@ -89,6 +89,7 @@ class EngineBuffer : public EngineObject { RubberBandFiner = 2, #endif }; + Q_ENUM(KeylockEngine); // intended for iteration over the KeylockEngine enum constexpr static std::initializer_list kKeylockEngines = { diff --git a/src/qml/qml_owned_ptr.h b/src/qml/qml_owned_ptr.h new file mode 100644 index 000000000000..58c68f815d9f --- /dev/null +++ b/src/qml/qml_owned_ptr.h @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include + +#include "util/assert.h" + +// Use this wrapper class to clearly represent a raw pointer that is owned by a +// QML Engine. Objects which derive from QObject, have their lifetime governed +// by the QML (or JavaScript) Engine, and thus such pointers do not require a +// manual delete to free the heap memory when they go out of scope, as they will +// be handled by the engine garbage collector. +template + requires(std::is_base_of_v) +class qml_owned_ptr final { + public: + explicit qml_owned_ptr(T* t = nullptr) noexcept + : m_ptr{t} { + if (m_ptr) { + QQmlEngine::setObjectOwnership(m_ptr, QQmlEngine::JavaScriptOwnership); + } + } + + // explicitly generate trivial destructor (since decltype(m_ptr) is not a class type) + ~qml_owned_ptr() noexcept = default; + + // Rule of 5 + qml_owned_ptr(const qml_owned_ptr& other) + : m_ptr{other.m_ptr} { + DEBUG_ASSERT(!m_ptr || + QQmlEngine::objectOwnership(m_ptr) == + QQmlEngine::JavaScriptOwnership); + } + qml_owned_ptr& operator=(const qml_owned_ptr&) = delete; + qml_owned_ptr(const qml_owned_ptr&& other) + : m_ptr{other.m_ptr} { + DEBUG_ASSERT(!m_ptr || + QQmlEngine::objectOwnership(m_ptr) == + QQmlEngine::JavaScriptOwnership); + } + qml_owned_ptr& operator=(const qml_owned_ptr&& other) = delete; + + // If U* is convertible to T* then qml_owned_ptr is convertible to qml_owned_ptr + template< + typename U, + typename = typename std::enable_if_t, U>> + qml_owned_ptr(qml_owned_ptr&& u) noexcept + : m_ptr{u.m_ptr} { + u.m_ptr = nullptr; + } + + // If U* is convertible to T* then qml_owned_ptr is assignable to qml_owned_ptr + template + requires std::is_convertible_v + qml_owned_ptr& operator=(qml_owned_ptr&& u) noexcept { + qml_owned_ptr temp{std::move(u)}; + std::swap(temp.m_ptr, m_ptr); + DEBUG_ASSERT(!m_ptr || + QQmlEngine::objectOwnership(m_ptr) == + QQmlEngine::JavaScriptOwnership); + return *this; + } + + qml_owned_ptr& operator=(std::nullptr_t) noexcept { + qml_owned_ptr{std::move(*this)}; // move *this into a temporary that gets destructed + return *this; + } + + // Prevent unintended invocation of delete on qml_owned_ptr + operator void*() const = delete; + + operator T*() const noexcept { + return m_ptr; + } + + T* get() const noexcept { + return m_ptr; + } + + T& operator*() const noexcept { + return *m_ptr; + } + + T* operator->() const noexcept { + return m_ptr; + } + + operator bool() const noexcept { + return m_ptr != nullptr; + } + + QPointer toWeakRef() { + return m_ptr; + } + + private: + T* m_ptr; +}; + +template +qml_owned_ptr make_qml_owned(Args&&... args) { + return qml_owned_ptr(new T(std::forward(args)...)); +} + +// A use case for this function is when giving an object owned by `std::unique_ptr` to a Qt +// function, that will make the object owned by the Qt object tree. Example: +// ``` +// parent->someFunctionThatAddsAChild(to_qml_owned(child)) +// ``` +// where `child` is a `std::unique_ptr`. After the call, the created `qml_owned_ptr` will +// automatically be destructed such that the DEBUG_ASSERT that checks whether a parent exists is +// triggered. +template +qml_owned_ptr to_qml_owned(std::unique_ptr& u) noexcept { + // the DEBUG_ASSERT in the qml_owned_ptr constructor will catch cases where + // the unique_ptr should not have been released + return qml_owned_ptr{u.release()}; +} diff --git a/src/qml/qmlsoundmanagerproxy.cpp b/src/qml/qmlsoundmanagerproxy.cpp new file mode 100644 index 000000000000..2ca419d348d4 --- /dev/null +++ b/src/qml/qmlsoundmanagerproxy.cpp @@ -0,0 +1,264 @@ +#include "qmlsoundmanagerproxy.h" + +#include + +#include + +#include "moc_qmlsoundmanagerproxy.cpp" +#include "qml_owned_ptr.h" +#include "soundio/soundmanager.h" +#include "soundio/soundmanagerutil.h" +#include "util/assert.h" +#include "util/scopedoverridecursor.h" + +namespace mixxx { +namespace qml { + +namespace { +const QString kAppGroup = QStringLiteral("[App]"); +const ConfigKey kKeylockEngineCfgkey = + ConfigKey(kAppGroup, QStringLiteral("keylock_engine")); + +} // namespace + +uint QmlSoundInputDeviceProxy::getChannelCount() const { + return m_pInternal->getNumInputChannels(); +} +uint QmlSoundOutputDeviceProxy::getChannelCount() const { + return m_pInternal->getNumOutputChannels(); +} +SoundDeviceId QmlSoundDeviceProxy::getDeviceId() const { + return m_pInternal->getDeviceId(); +} + +QList QmlSoundInputDeviceProxy::connections( + mixxx::qml::QmlSoundManagerProxy* manager) { + DEBUG_ASSERT(qml_owned_ptr(manager)); + QList connections; + + auto pManager = manager->internal(); + auto config = pManager->getConfig(); + + const auto inputDeviceMap = config.getInputs(); + for (auto it = inputDeviceMap.cbegin(); it != inputDeviceMap.cend(); ++it) { + if (it.key() == getDeviceId()) { + connections.push_back(make_qml_owned( + std::make_unique(it.value()), this)); + } + } + return connections; +} + +QList QmlSoundOutputDeviceProxy::connections( + mixxx::qml::QmlSoundManagerProxy* manager) { + DEBUG_ASSERT(qml_owned_ptr(manager)); + QList connections; + + auto pManager = manager->internal(); + auto config = pManager->getConfig(); + const auto ouputDeviceMap = config.getOutputs(); + for (auto it = ouputDeviceMap.cbegin(); it != ouputDeviceMap.cend(); ++it) { + if (it.key() == getDeviceId()) { + connections.push_back(make_qml_owned( + std::make_unique(it.value()), this)); + } + } + return connections; +} + +int QmlSoundDeviceConnection::getType() const { + return static_cast(m_audioPath->getType()); +} + +uchar QmlSoundDeviceConnection::getChannelGroup() const { + auto group = m_audioPath->getChannelGroup(); + return group.getChannelBase(); +} +uchar QmlSoundDeviceConnection::getIndex() const { + return m_audioPath->getIndex(); +} + +QmlSoundManagerProxy::QmlSoundManagerProxy( + std::shared_ptr pSoundManager, + QObject* parent) + : QObject(parent), + m_pSoundManager(pSoundManager), + m_keylockEngine(kKeylockEngineCfgkey), + m_config(m_pSoundManager->getConfig()) { + connect(m_pSoundManager.get(), &SoundManager::devicesClosed, this, [this]() { + SoundDeviceStatus status = SoundDeviceStatus::Ok; + { + ScopedWaitCursor cursor; + + if (m_commitInProgress.fetchAndStoreRelease(0) != 1) { + return; + } + + status = m_pSoundManager->setConfig(m_config); + } + if (status != SoundDeviceStatus::Ok) { + emit committed(m_pSoundManager->getLastErrorMessage(status)); + } else { + emit committed(); + } + m_config = m_pSoundManager->getConfig(); + }); +} + +// static +QmlSoundManagerProxy* QmlSoundManagerProxy::create( + QQmlEngine* pQmlEngine, + QJSEngine*) { + // The instance has to exist before it is used. We cannot replace it. + VERIFY_OR_DEBUG_ASSERT(s_pSoundManager) { + qWarning() << "SoundManager hasn't been registered yet"; + return nullptr; + } + return make_qml_owned(s_pSoundManager, pQmlEngine); +} + +QList QmlSoundManagerProxy::getHostAPIList() const { + return m_pSoundManager->getHostAPIList(); +} + +QList QmlSoundManagerProxy::availableInputDevices(const QString& filterAPI) { + QList devices; + + for (const auto& device : m_pSoundManager->getDeviceList(filterAPI, false, true)) { + devices.push_back(make_qml_owned(device, this)); + } + + return devices; +} + +QList QmlSoundManagerProxy::availableOutputDevices(const QString& filterAPI) { + QList devices; + + for (const auto& device : m_pSoundManager->getDeviceList(filterAPI, true, false)) { + devices.push_back(make_qml_owned(device, this)); + } + + return devices; +} + +QList QmlSoundManagerProxy::getKeylockEngines() const { + QList list; + for (const auto engine : EngineBuffer::kKeylockEngines) { + if (EngineBuffer::isKeylockEngineAvailable(engine)) { + list.append(engine); + } + } + return list; +} + +void QmlSoundManagerProxy::setKeylockEngine(EngineBuffer::KeylockEngine keylockEngine) { + m_keylockEngine.set(static_cast(keylockEngine)); + m_pSoundManager->userSettings()->setValue(kKeylockEngineCfgkey, keylockEngine); +} + +EngineBuffer::KeylockEngine QmlSoundManagerProxy::getKeylockEngine() const { + return m_pSoundManager->userSettings() + ->getValue( + kKeylockEngineCfgkey, EngineBuffer::defaultKeylockEngine()); +} + +QString QmlSoundManagerProxy::getAPI() const { + return m_config.getAPI(); +} +void QmlSoundManagerProxy::setAPI(const QString& api) { + m_config.setAPI(api); +} + +unsigned int QmlSoundManagerProxy::getSyncBuffers() const { + return m_config.getSyncBuffers(); +} + +void QmlSoundManagerProxy::setSyncBuffers(unsigned int syncBuffers) { + m_config.setSyncBuffers(syncBuffers); +} + +uint32_t QmlSoundManagerProxy::getSampleRate() const { + return m_config.getSampleRate(); +} + +void QmlSoundManagerProxy::setSampleRate(uint32_t sampleRate) { + m_config.setSampleRate(mixxx::audio::SampleRate(sampleRate)); +} + +QList QmlSoundManagerProxy::getSampleRates(const QString& filterAPI) const { + QList sampleRates; + for (const auto& sampleRate : m_pSoundManager->getSampleRates(filterAPI)) { + if (sampleRate.isValid()) { + sampleRates.append(sampleRate); + } + } + return sampleRates; +} + +bool QmlSoundManagerProxy::getForceNetworkClock() const { + return m_config.getForceNetworkClock(); +} + +void QmlSoundManagerProxy::setForceNetworkClock(bool force) { + m_config.setForceNetworkClock(force); +} + +unsigned int QmlSoundManagerProxy::getAudioBufferSizeIndex() const { + return m_config.getAudioBufferSizeIndex(); +} + +void QmlSoundManagerProxy::setAudioBufferSizeIndex(unsigned int latency) { + m_config.setAudioBufferSizeIndex(latency); +} + +void QmlSoundManagerProxy::addOutput(QmlSoundOutputDeviceProxy* device, + int type, + unsigned char channelGroup, + unsigned char index) { + VERIFY_OR_DEBUG_ASSERT(device && qml_owned_ptr(device)) { + return; + } + m_config.addOutput(device->getDeviceId(), + AudioOutput(static_cast(type), + channelGroup, + mixxx::audio::ChannelCount::stereo(), + index)); +} + +void QmlSoundManagerProxy::addInput(QmlSoundInputDeviceProxy* device, + int type, + unsigned char channelGroup, + unsigned char index) { + VERIFY_OR_DEBUG_ASSERT(device && qml_owned_ptr(device)) { + return; + } + m_config.addInput(device->getDeviceId(), + AudioInput(static_cast(type), + channelGroup, + mixxx::audio::ChannelCount::stereo(), + index)); +} + +void QmlSoundManagerProxy::clearOutputs() { + m_config.clearOutputs(); +} + +void QmlSoundManagerProxy::clearInputs() { + m_config.clearInputs(); +} + +bool QmlSoundManagerProxy::hasMicInputs() { + return m_config.hasMicInputs(); +} + +std::shared_ptr QmlSoundManagerProxy::internal() const { + return m_pSoundManager; +} + +void QmlSoundManagerProxy::commit() { + m_commitInProgress.storeRelease(1); + m_pSoundManager->closeActiveConfig(true); +} + +} // namespace qml +} // namespace mixxx diff --git a/src/qml/qmlsoundmanagerproxy.h b/src/qml/qmlsoundmanagerproxy.h new file mode 100644 index 000000000000..916546ecc938 --- /dev/null +++ b/src/qml/qmlsoundmanagerproxy.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +#include "control/pollingcontrolproxy.h" +#include "engine/enginebuffer.h" +#include "qml_owned_ptr.h" +#include "soundio/sounddevice.h" +#include "soundio/soundmanagerconfig.h" + +class SoundManager; + +namespace mixxx { +namespace qml { + +class QmlSoundManagerProxy; +class QmlSoundDeviceConnection; +class QmlSoundDeviceProxy : public QObject { + Q_OBJECT + Q_PROPERTY(QString displayName READ getDisplayName CONSTANT) + Q_PROPERTY(uint channelCount READ getChannelCount CONSTANT) + QML_ANONYMOUS + public: + explicit QmlSoundDeviceProxy(SoundDevicePointer pInternal, QObject* parent) + : QObject(parent), + m_pInternal(std::move(pInternal)) { + } + + QString getDisplayName() const { + return m_pInternal->getDisplayName(); + } + + virtual uint getChannelCount() const = 0; + SoundDeviceId getDeviceId() const; + + Q_INVOKABLE virtual QList connections( + mixxx::qml::QmlSoundManagerProxy* manager) = 0; + + protected: + SoundDevicePointer m_pInternal; +}; + +class QmlSoundInputDeviceProxy : public QmlSoundDeviceProxy { + Q_OBJECT + QML_NAMED_ELEMENT(InputDevice) + QML_UNCREATABLE("Use Mixxx.SoundManager to get devices") + public: + explicit QmlSoundInputDeviceProxy(SoundDevicePointer pInternal, QObject* parent) + : QmlSoundDeviceProxy(std::move(pInternal), parent) { + } + uint getChannelCount() const override; + Q_INVOKABLE QList connections( + mixxx::qml::QmlSoundManagerProxy* manager) override; +}; + +class QmlSoundOutputDeviceProxy : public QmlSoundDeviceProxy { + Q_OBJECT + QML_NAMED_ELEMENT(OutputDevice) + QML_UNCREATABLE("Use Mixxx.SoundManager to get devices") + public: + explicit QmlSoundOutputDeviceProxy(SoundDevicePointer pInternal, QObject* parent) + : QmlSoundDeviceProxy(std::move(pInternal), parent) { + } + uint getChannelCount() const override; + Q_INVOKABLE QList connections( + mixxx::qml::QmlSoundManagerProxy* manager) override; +}; + +class QmlSoundDeviceConnection : public QObject { + Q_OBJECT + Q_PROPERTY(int type READ getType CONSTANT) + Q_PROPERTY(uchar channelGroup READ getChannelGroup CONSTANT) + Q_PROPERTY(uchar index READ getIndex CONSTANT) + QML_ANONYMOUS + public: + QmlSoundDeviceConnection(std::unique_ptr path, QObject* parent = nullptr) + : QObject(parent), + m_audioPath(std::move(path)) { + } + + int getType() const; + uchar getChannelGroup() const; + uchar getIndex() const; + + private: + std::unique_ptr m_audioPath; +}; + +class QmlSoundDeviceInputConnection : public QmlSoundDeviceConnection { + Q_OBJECT + QML_NAMED_ELEMENT(InputConnection) + QML_UNCREATABLE("Use Mixxx.SoundDevice to get connections") + public: + QmlSoundDeviceInputConnection(std::unique_ptr path, QObject* parent = nullptr) + : QmlSoundDeviceConnection(std::move(path), parent) { + } +}; + +class QmlSoundDeviceOutputConnection : public QmlSoundDeviceConnection { + Q_OBJECT + QML_NAMED_ELEMENT(OutputConnection) + QML_UNCREATABLE("Use Mixxx.SoundDevice to get connections") + public: + QmlSoundDeviceOutputConnection(std::unique_ptr path, QObject* parent = nullptr) + : QmlSoundDeviceConnection(std::move(path), parent) { + } +}; + +class QmlSoundManagerProxy : public QObject { + Q_OBJECT + QML_NAMED_ELEMENT(SoundManager) + QML_SINGLETON + public: + explicit QmlSoundManagerProxy( + std::shared_ptr pSoundManager, + QObject* parent = nullptr); + + Q_INVOKABLE QList getHostAPIList() const; + Q_INVOKABLE QList availableInputDevices( + const QString& filterAPI); + Q_INVOKABLE QList availableOutputDevices( + const QString& filterAPI); + + Q_INVOKABLE QList getKeylockEngines() const; + Q_INVOKABLE EngineBuffer::KeylockEngine getKeylockEngine() const; + Q_INVOKABLE void setKeylockEngine(EngineBuffer::KeylockEngine); + Q_INVOKABLE QString getAPI() const; + Q_INVOKABLE void setAPI(const QString& api); + Q_INVOKABLE unsigned int getSyncBuffers() const; + Q_INVOKABLE void setSyncBuffers(unsigned int syncBuffers); + Q_INVOKABLE uint32_t getSampleRate() const; + Q_INVOKABLE void setSampleRate(uint32_t sampleRate); + Q_INVOKABLE bool getForceNetworkClock() const; + Q_INVOKABLE void setForceNetworkClock(bool force); + Q_INVOKABLE unsigned int getAudioBufferSizeIndex() const; + Q_INVOKABLE void setAudioBufferSizeIndex(unsigned int latency); + Q_INVOKABLE QList getSampleRates(const QString& filterAPI) const; + Q_INVOKABLE void addOutput(mixxx::qml::QmlSoundOutputDeviceProxy* device, + int type, + unsigned char channelGroup, + unsigned char index); + Q_INVOKABLE void addInput(mixxx::qml::QmlSoundInputDeviceProxy* device, + int type, + unsigned char channelGroup, + unsigned char index); + Q_INVOKABLE void clearOutputs(); + Q_INVOKABLE void clearInputs(); + Q_INVOKABLE bool hasMicInputs(); + + std::shared_ptr internal() const; + Q_INVOKABLE void commit(); + + static QmlSoundManagerProxy* create(QQmlEngine* pQmlEngine, QJSEngine* pJsEngine); + static void registerManager(std::shared_ptr pManager) { + s_pSoundManager = std::move(pManager); + } + + signals: + void committed(const QString& error = {}); + + private: + static inline std::shared_ptr s_pSoundManager; + + PollingControlProxy m_keylockEngine; + + std::shared_ptr m_pSoundManager; + SoundManagerConfig m_config; + QAtomicInt m_commitInProgress; +}; + +} // namespace qml +} // namespace mixxx diff --git a/src/soundio/soundmanager.cpp b/src/soundio/soundmanager.cpp index 273211831cac..4241696aa37d 100644 --- a/src/soundio/soundmanager.cpp +++ b/src/soundio/soundmanager.cpp @@ -145,27 +145,48 @@ QList SoundManager::getHostAPIList() const { return apiList; } -void SoundManager::closeDevices(bool sleepAfterClosing) { - //qDebug() << "SoundManager::closeDevices()"; +void SoundManager::closeDevices( + [[maybe_unused]] bool sleepAfterClosing, [[maybe_unused]] bool async) { + // sleepAfterClosing and async maybe unused depending on platform support + // qDebug() << "SoundManager::closeDevices()"; +#ifdef __LINUX__ bool closed = false; +#endif for (const auto& pDevice : std::as_const(m_devices)) { if (pDevice->isOpen()) { // NOTE(rryan): As of 2009 (?) it has been safe to close() a SoundDevice // while callbacks are active. pDevice->close(); +#ifdef __LINUX__ closed = true; +#endif } } - if (closed && sleepAfterClosing) { #ifdef __LINUX__ + if (closed && sleepAfterClosing) { // Sleep for 5 sec to allow asynchronously sound APIs like "pulse" to free // its resources as well + if (async) { + // Async mode - the caller will wait for `devicesClosed` before + // trying to reconfigure or reopen audio devices + QTimer::singleShot( + std::chrono::seconds(kSleepSecondsAfterClosingDevice), + this, + &SoundManager::completeDevicesClosing); + return; + } + // Sync mode, legacy - we sleep the current thread for 5 seconds QThread::sleep(kSleepSecondsAfterClosingDevice); + } else if (!closed) #endif + { + completeDevicesClosing(); } +} +void SoundManager::completeDevicesClosing() { // TODO(rryan): Should we do this before SoundDevice::close()? No! Because // then the callback may be running when we call // onInputDisconnected/onOutputDisconnected. @@ -199,6 +220,7 @@ void SoundManager::closeDevices(bool sleepAfterClosing) { // Indicate to the rest of Mixxx that sound is disconnected. m_pControlObjectSoundStatusCO->set(SOUNDMANAGER_DISCONNECTED); + emit devicesClosed(); } void SoundManager::clearDeviceList(bool sleepAfterClosing) { @@ -553,12 +575,12 @@ SoundManagerConfig SoundManager::getConfig() const { return m_config; } -void SoundManager::closeActiveConfig() { +void SoundManager::closeActiveConfig(bool async) { // Close open devices. After this call we will not get any more // onDeviceOutputCallback() or pushBuffer() calls because all the // SoundDevices are closed. closeDevices() blocks and can take a while. const bool sleepAfterClosing = true; - closeDevices(sleepAfterClosing); + closeDevices(sleepAfterClosing, async); } SoundDeviceStatus SoundManager::setConfig(const SoundManagerConfig& config) { diff --git a/src/soundio/soundmanager.h b/src/soundio/soundmanager.h index 205909535f4b..a5f4e92138cd 100644 --- a/src/soundio/soundmanager.h +++ b/src/soundio/soundmanager.h @@ -74,7 +74,12 @@ class SoundManager : public QObject { QList getHostAPIList() const; SoundManagerConfig getConfig() const; SoundDeviceStatus setConfig(const SoundManagerConfig& config); - void closeActiveConfig(); + // Due to a bug in in PulseAudio, we must give at least 5 seconds of cool + // down before performing further audio related operation. This sleep + // happens during the function call by default (synchronous blocking), but + // the caller may decide to use the async version, and must not performs any + // audio operation till it received the `devicesClosed` signal + void closeActiveConfig(bool async = false); void checkConfig(); void onDeviceOutputCallback(const SINT iFramesPerBuffer); @@ -107,12 +112,20 @@ class SoundManager : public QObject { void processUnderflowHappened(SINT framesPerBuffer); + UserSettingsPointer userSettings() const { + return m_pConfig; + } + signals: void devicesUpdated(); // emitted when pointers to SoundDevices go stale void devicesSetup(); // emitted when the sound devices have been set up + void devicesClosed(); // emitted when the sound devices have been closed and resources freed void outputRegistered(const AudioOutput& output, AudioSource* src); void inputRegistered(const AudioInput& input, AudioDestination* dest); + private slots: + void completeDevicesClosing(); + private: // Closes all the devices and empties the list of devices we have. void clearDeviceList(bool sleepAfterClosing); @@ -121,7 +134,7 @@ class SoundManager : public QObject { // open, this method simply runs through the list of all known soundcards // (from PortAudio) and attempts to close them all. Closing a soundcard that // isn't open is safe. - void closeDevices(bool sleepAfterClosing); + void closeDevices(bool sleepAfterClosing, bool async = false); void setJACKName() const; bool jackApiUsed() const {