feat: add nexus audio page

This commit is contained in:
2 * r + 2 * t 2026-06-07 16:57:58 +10:00
parent a9737915e3
commit 72dc5a8b93
5 changed files with 335 additions and 5 deletions

View file

@ -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

View file

@ -51,6 +51,14 @@ QtObject {
BluetoothPairing {}
}
}
},
Component {
// Audio
StackPage {
Component {
AudioPage {}
}
}
}
]
}

View file

@ -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
}
}
}
}
}
}

View file

@ -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)
}
}
}
}
}

View file

@ -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
}
}
}
}
}