From 72dc5a8b932250dd175436b456a50521600fa0f0 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:57:58 +1000 Subject: [PATCH] feat: add nexus audio page --- components/controls/StyledSlider.qml | 11 +- modules/nexus/PageCompRegistry.qml | 8 ++ modules/nexus/common/AudioDeviceList.qml | 97 ++++++++++++++++ modules/nexus/common/SliderRow.qml | 89 +++++++++++++++ modules/nexus/pages/AudioPage.qml | 135 +++++++++++++++++++++++ 5 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 modules/nexus/common/AudioDeviceList.qml create mode 100644 modules/nexus/common/SliderRow.qml create mode 100644 modules/nexus/pages/AudioPage.qml diff --git a/components/controls/StyledSlider.qml b/components/controls/StyledSlider.qml index 91c8040a..751b7586 100644 --- a/components/controls/StyledSlider.qml +++ b/components/controls/StyledSlider.qml @@ -15,6 +15,7 @@ Slider { property bool animateWave property real waveFrequency: 6 property int waveDuration: 1000 + property int radius: Tokens.rounding.medium property color fgColour: enabled ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.38) property color bgColour: enabled ? Colours.palette.m3secondaryContainer : Qt.alpha(Colours.palette.m3onSurface, 0.1) @@ -43,7 +44,7 @@ Slider { implicitHeight: parent.height * (parent.height <= 12 ? opacity : Math.min(opacity * 2, 1)) opacity: Math.min(width, 12) / 12 - radius: Tokens.rounding.medium + radius: root.radius topLeftRadius: Tokens.rounding.extraSmall / 2 bottomLeftRadius: Tokens.rounding.extraSmall / 2 color: root.bgColour @@ -71,9 +72,9 @@ Slider { implicitWidth: 4 implicitHeight: { - const mult = parent.height <= 12 ? 3 : 1.2; - const pressMult = parent.height <= 12 ? 4 : 1.5; - return parent.height * (mouse.pressed ? pressMult : mult); + const t = CUtils.clamp((parent.height - 12) / 16, 0, 1); + const lerp = (a, b) => a + (b - a) * t; + return parent.height * (mouse.pressed ? lerp(3.5, 1.5) : lerp(3, 1.2)); } radius: Tokens.rounding.full @@ -103,7 +104,7 @@ Slider { implicitWidth: root.filledWidth implicitHeight: root.height - radius: Tokens.rounding.medium + radius: root.radius topRightRadius: Tokens.rounding.extraSmall / 2 bottomRightRadius: Tokens.rounding.extraSmall / 2 color: root.fgColour diff --git a/modules/nexus/PageCompRegistry.qml b/modules/nexus/PageCompRegistry.qml index bce3527a..1d28ae6d 100644 --- a/modules/nexus/PageCompRegistry.qml +++ b/modules/nexus/PageCompRegistry.qml @@ -51,6 +51,14 @@ QtObject { BluetoothPairing {} } } + }, + Component { + // Audio + StackPage { + Component { + AudioPage {} + } + } } ] } diff --git a/modules/nexus/common/AudioDeviceList.qml b/modules/nexus/common/AudioDeviceList.qml new file mode 100644 index 00000000..74180b54 --- /dev/null +++ b/modules/nexus/common/AudioDeviceList.qml @@ -0,0 +1,97 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus.common + +ItemList { + id: root + + property var nodes: [] + property int currentId: -1 + property string iconName: "speaker" + + signal selected(node: PwNode) + + last: true + showList: true + + model: ScriptModel { + values: [...root.nodes].sort((a, b) => (a.description || a.name || "").localeCompare(b.description || b.name || "")) + } + + delegate: Item { + id: device + + required property PwNode modelData + required property int index + readonly property bool active: device.modelData?.id === root.currentId + + anchors.left: root.list.contentItem.left + anchors.right: root.list.contentItem.right + implicitHeight: deviceLayout.implicitHeight + deviceLayout.anchors.margins * 2 + + StateLayer { + radius: Tokens.rounding.extraSmall + bottomLeftRadius: device.index === root.list.count - 1 ? Tokens.rounding.extraLarge : radius + bottomRightRadius: device.index === root.list.count - 1 ? Tokens.rounding.extraLarge : radius + onClicked: root.selected(device.modelData) + } + + RowLayout { + id: deviceLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: devIcon.implicitHeight + Tokens.padding.small * 2 + radius: Tokens.rounding.full + color: device.active ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer + + MaterialIcon { + id: devIcon + + anchors.centerIn: parent + text: root.iconName + color: device.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer + font: Tokens.font.icon.medium + fill: device.active ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + } + + StyledText { + Layout.fillWidth: true + text: device.modelData?.description || device.modelData?.name || qsTr("Unknown") + font: Tokens.font.body.small + elide: Text.ElideRight + } + + MaterialIcon { + text: "check" + color: Colours.palette.m3primary + font: Tokens.font.icon.medium + opacity: device.active ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + } + } +} diff --git a/modules/nexus/common/SliderRow.qml b/modules/nexus/common/SliderRow.qml new file mode 100644 index 00000000..4fa08534 --- /dev/null +++ b/modules/nexus/common/SliderRow.qml @@ -0,0 +1,89 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus.common + +ConnectedRect { + id: root + + property alias icon: icon.text + property alias label: label.text + property alias valueLabel: valueLabel.text + property real value + + signal moved(value: real) + + implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins * 2 + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.large + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + MaterialIcon { + id: icon + + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.medium + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + id: valueLabel + + color: Colours.palette.m3outline + font: Tokens.font.body.small + } + } + + CustomMouseArea { + function onWheel(event: WheelEvent): void { + const step = GlobalConfig.services.audioIncrement; + if (event.angleDelta.y > 0) + root.moved(Math.min(1, root.value + step)); + else if (event.angleDelta.y < 0) + root.moved(Math.max(0, root.value - step)); + } + + Layout.fillWidth: true + implicitHeight: Tokens.padding.medium * 2 + + StyledSlider { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: parent.implicitHeight + + radius: Tokens.rounding.small + value: root.value + enabled: root.enabled + onInteraction: v => root.moved(v) + } + } + } + } +} diff --git a/modules/nexus/pages/AudioPage.qml b/modules/nexus/pages/AudioPage.qml new file mode 100644 index 00000000..8d0dac48 --- /dev/null +++ b/modules/nexus/pages/AudioPage.qml @@ -0,0 +1,135 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.utils +import qs.modules.nexus.common +import qs.modules.nexus.pages.audio + +PageBase { + id: root + + title: qsTr("Audio") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // Output + SliderRow { + Layout.fillWidth: true + first: true + icon: Icons.getVolumeIcon(Audio.volume, Audio.muted) + label: qsTr("Output") + valueLabel: Math.round(value * 100) + "%" + value: Audio.volume + enabled: !Audio.muted + onMoved: v => Audio.setVolume(v) + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Muted") + checked: Audio.muted + onToggled: Audio.setStreamMuted(Audio.sink, checked) + } + + AudioDeviceList { + nodes: Audio.sinks + currentId: Audio.sink?.id ?? -1 + iconName: "speaker" + placeholderIcon: "speaker" + placeholderText: qsTr("No output devices") + onSelected: node => Audio.setAudioSink(node) + } + + // Input + SliderRow { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.large - parent.spacing + first: true + icon: Icons.getMicVolumeIcon(Audio.sourceVolume, Audio.sourceMuted) + label: qsTr("Input") + valueLabel: Math.round(value * 100) + "%" + value: Audio.sourceVolume + enabled: !Audio.sourceMuted + onMoved: v => Audio.setSourceVolume(v) + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Muted") + checked: Audio.sourceMuted + onToggled: Audio.setStreamMuted(Audio.source, checked) + } + + AudioDeviceList { + nodes: Audio.sources + currentId: Audio.source?.id ?? -1 + iconName: "mic" + placeholderIcon: "mic_off" + placeholderText: qsTr("No input devices") + onSelected: node => Audio.setAudioSource(node) + } + + // Per-app volumes + ConnectedRect { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.large - parent.spacing + implicitHeight: appLayout.implicitHeight + appLayout.anchors.margins * 2 + first: true + last: true + + StateLayer { + onClicked: root.nState.openSubPage(1) + } + + RowLayout { + id: appLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + MaterialIcon { + text: "tune" + font: Tokens.font.icon.medium + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: qsTr("App volumes") + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: Audio.streams.length === 0 ? qsTr("No apps playing audio") : Audio.streams.length === 1 ? qsTr("1 app playing audio") : qsTr("%1 apps playing audio").arg(Audio.streams.length) + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + animate: true + } + } + + MaterialIcon { + text: "chevron_right" + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.medium + } + } + } + } +}