bar/popouts: add audio device switcher (#319)

* feat: basic audio switcher

* feat: replace VerticalSlider with StyledSlider

* fix: styling

* fix: formatting

* chore: make sound icons consistent, change slider styling

* feat: styled slider component variants

* chore: cleanup

* chore: cleanup

* fix: pr fixes

* fix: remove redundant code

* chore: remove old code

* fix: controls styling

* fixes

* more tweaks

* radiobtn: add interaction stuff

Anim slider

---------

Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
This commit is contained in:
Laurens Duin 2025-08-05 08:04:53 +02:00 committed by GitHub
parent e4d4c457f8
commit 9ada66a78e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 277 additions and 154 deletions

View file

@ -25,7 +25,7 @@ Slider {
implicitHeight: parent.height - y
color: Colours.alpha(Colours.palette.m3secondary, true)
radius: Appearance.rounding.full
radius: parent.radius
}
}

View file

@ -0,0 +1,58 @@
import qs.components
import qs.services
import qs.config
import QtQuick
import QtQuick.Controls
RadioButton {
id: root
font.pointSize: Appearance.font.size.smaller
indicator: Rectangle {
id: outerCircle
implicitWidth: 20
implicitHeight: 20
radius: Appearance.rounding.full
color: "transparent"
border.color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
border.width: 2
anchors.verticalCenter: parent.verticalCenter
StateLayer {
anchors.margins: -Appearance.padding.smaller
color: root.checked ? Colours.palette.m3onSurface : Colours.palette.m3primary
z: -1
function onClicked(): void {
root.click();
}
}
StyledRect {
anchors.centerIn: parent
implicitWidth: 8
implicitHeight: 8
radius: Appearance.rounding.full
color: root.checked ? Colours.palette.m3primary : "transparent"
}
Behavior on border.color {
ColorAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}
}
contentItem: StyledText {
text: root.text
font.pointSize: root.font.pointSize
anchors.verticalCenter: parent.verticalCenter
anchors.left: outerCircle.right
anchors.leftMargin: Appearance.spacing.smaller
}
}

View file

@ -0,0 +1,59 @@
import qs.components
import qs.config
import qs.services
import QtQuick.Controls
import QtQuick
Slider {
id: slider
background: Item {
StyledRect {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.topMargin: slider.implicitHeight / 3
anchors.bottomMargin: slider.implicitHeight / 3
implicitWidth: slider.handle.x - slider.implicitHeight / 6
color: Colours.palette.m3primary
radius: Appearance.rounding.full
topRightRadius: slider.implicitHeight / 15
bottomRightRadius: slider.implicitHeight / 15
}
StyledRect {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.topMargin: slider.implicitHeight / 3
anchors.bottomMargin: slider.implicitHeight / 3
implicitWidth: parent.width - slider.handle.x - slider.handle.implicitWidth - slider.implicitHeight / 6
color: Colours.palette.m3surfaceContainer
radius: Appearance.rounding.full
topLeftRadius: slider.implicitHeight / 15
bottomLeftRadius: slider.implicitHeight / 15
}
}
handle: StyledRect {
id: rect
x: slider.visualPosition * slider.availableWidth
implicitWidth: slider.implicitHeight / 4.5
implicitHeight: slider.implicitHeight
color: Colours.palette.m3primary
radius: Appearance.rounding.full
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: event => event.accepted = false
}
}
}

View file

@ -58,7 +58,7 @@ Item {
sourceComponent: MaterialIcon {
animate: true
text: Audio.muted ? "volume_off" : Audio.volume >= 0.66 ? "volume_up" : Audio.volume >= 0.33 ? "volume_down" : "volume_mute"
text: Icons.getVolumeIcon(Audio.volume, Audio.muted)
color: root.colour
}
}

View file

@ -1,58 +1,139 @@
pragma ComponentBehavior: Bound
import qs.components
import qs.components.controls
import qs.services
import qs.config
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Pipewire
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
ColumnLayout {
Item {
id: root
required property var wrapper
spacing: Appearance.spacing.normal
implicitWidth: layout.implicitWidth + Appearance.padding.normal * 2
implicitHeight: layout.implicitHeight + Appearance.padding.normal * 2
VerticalSlider {
id: volumeSlider
icon: {
if (Audio.muted)
return "no_sound";
if (value >= 0.5)
return "volume_up";
if (value > 0)
return "volume_down";
return "volume_mute";
}
value: Audio.volume
onMoved: Audio.setVolume(value)
implicitWidth: Config.osd.sizes.sliderWidth
implicitHeight: Config.osd.sizes.sliderHeight
ButtonGroup {
id: sinks
}
StyledRect {
id: pavuButton
ButtonGroup {
id: sources
}
implicitWidth: implicitHeight
implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
ColumnLayout {
id: layout
radius: Appearance.rounding.normal
color: Colours.palette.m3surfaceContainer
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 0
StateLayer {
function onClicked(): void {
root.wrapper.hasCurrent = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.audio]);
StyledText {
Layout.bottomMargin: Appearance.spacing.small / 2
text: qsTr("Output")
font.weight: 500
}
Repeater {
model: Audio.sinks
StyledRadioButton {
id: control
required property PwNode modelData
ButtonGroup.group: sinks
checked: Audio.sink?.id === modelData.id
onClicked: Audio.setAudioSink(modelData)
text: modelData.description
}
}
MaterialIcon {
id: icon
StyledText {
Layout.topMargin: Appearance.spacing.small
Layout.bottomMargin: Appearance.spacing.small / 2
text: qsTr("Input")
font.weight: 500
}
anchors.centerIn: parent
text: "settings"
Repeater {
model: Audio.sources
StyledRadioButton {
required property PwNode modelData
ButtonGroup.group: sources
checked: Audio.source?.id === modelData.id
onClicked: Audio.setAudioSource(modelData)
text: modelData.description
}
}
StyledText {
Layout.topMargin: Appearance.spacing.small
Layout.bottomMargin: Appearance.spacing.small / 2
text: qsTr("Volume")
font.weight: 500
}
StyledSlider {
Layout.fillWidth: true
implicitHeight: Appearance.padding.normal * 3
value: Audio.volume
onMoved: Audio.setVolume(value)
Behavior on value {
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}
}
StyledRect {
Layout.topMargin: Appearance.spacing.normal
visible: Config.general.apps.audio.length > 0
implicitWidth: expandBtn.implicitWidth + Appearance.padding.normal * 2
implicitHeight: expandBtn.implicitHeight + Appearance.padding.small
radius: Appearance.rounding.normal
color: Colours.palette.m3primaryContainer
StateLayer {
color: Colours.palette.m3onPrimaryContainer
function onClicked(): void {
root.wrapper.hasCurrent = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.audio]);
}
}
RowLayout {
id: expandBtn
anchors.centerIn: parent
spacing: Appearance.spacing.small
StyledText {
Layout.leftMargin: Appearance.padding.smaller
text: qsTr("Open settings")
color: Colours.palette.m3onPrimaryContainer
}
MaterialIcon {
text: "chevron_right"
color: Colours.palette.m3onPrimaryContainer
font.pointSize: Appearance.font.size.large
}
}
}
}
}

View file

@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import qs.components
import qs.components.effects
import qs.components.misc
import qs.components.controls
import qs.services
import qs.utils
import qs.config
@ -279,9 +280,10 @@ Item {
}
}
Slider {
StyledSlider {
id: slider
enabled: !!Players.active
implicitWidth: controls.implicitWidth * 1.5
implicitHeight: Appearance.padding.normal * 3
@ -291,56 +293,6 @@ Item {
if (active?.canSeek && active?.positionSupported)
active.position = value * active.length;
}
background: Item {
StyledRect {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.topMargin: slider.implicitHeight / 3
anchors.bottomMargin: slider.implicitHeight / 3
implicitWidth: slider.handle.x - slider.implicitHeight / 6
color: Colours.palette.m3primary
radius: Appearance.rounding.full
topRightRadius: slider.implicitHeight / 15
bottomRightRadius: slider.implicitHeight / 15
}
StyledRect {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.topMargin: slider.implicitHeight / 3
anchors.bottomMargin: slider.implicitHeight / 3
implicitWidth: parent.width - slider.handle.x - slider.handle.implicitWidth - slider.implicitHeight / 6
color: Colours.palette.m3surfaceContainer
radius: Appearance.rounding.full
topLeftRadius: slider.implicitHeight / 15
bottomLeftRadius: slider.implicitHeight / 15
}
}
handle: StyledRect {
id: rect
x: slider.visualPosition * slider.availableWidth
implicitWidth: slider.implicitHeight / 4.5
implicitHeight: slider.implicitHeight
color: Colours.palette.m3primary
radius: Appearance.rounding.full
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: event => event.accepted = false
}
}
}
Item {

View file

@ -1,4 +1,5 @@
import qs.components
import qs.components.controls
import qs.services
import qs.config
import Quickshell.Widgets
@ -164,7 +165,7 @@ RowLayout {
spacing: Appearance.spacing.small
Slider {
StyledSlider {
id: slider
Layout.rightMargin: root.isLarge ? Appearance.spacing.small : 0
@ -177,56 +178,6 @@ RowLayout {
if (active?.canSeek && active?.positionSupported)
active.position = value * active.length;
}
background: Item {
StyledRect {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.topMargin: slider.implicitHeight / 3
anchors.bottomMargin: slider.implicitHeight / 3
implicitWidth: slider.handle.x - slider.implicitHeight / 6
color: Colours.palette.m3primary
radius: Appearance.rounding.full
topRightRadius: slider.implicitHeight / 15
bottomRightRadius: slider.implicitHeight / 15
}
StyledRect {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.topMargin: slider.implicitHeight / 3
anchors.bottomMargin: slider.implicitHeight / 3
implicitWidth: parent.width - slider.handle.x - slider.handle.implicitWidth - slider.implicitHeight / 6
color: Colours.palette.m3surfaceContainer
radius: Appearance.rounding.full
topLeftRadius: slider.implicitHeight / 15
bottomLeftRadius: slider.implicitHeight / 15
}
}
handle: StyledRect {
id: rect
x: slider.visualPosition * slider.availableWidth
implicitWidth: slider.implicitHeight / 4.5
implicitHeight: slider.implicitHeight
color: Colours.palette.m3primary
radius: Appearance.rounding.full
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: event => event.accepted = false
}
}
}
Control {

View file

@ -1,6 +1,7 @@
import qs.components.controls
import qs.services
import qs.config
import qs.utils
import QtQuick
Column {
@ -26,18 +27,10 @@ Column {
Audio.setVolume(Audio.volume - 0.1);
}
VerticalSlider {
FilledSlider {
anchors.fill: parent
icon: {
if (Audio.muted)
return "no_sound";
if (value >= 0.5)
return "volume_up";
if (value > 0)
return "volume_down";
return "volume_mute";
}
icon: Icons.getVolumeIcon(value, Audio.muted)
value: Audio.volume
onMoved: Audio.setVolume(value)
}
@ -57,7 +50,7 @@ Column {
monitor.setBrightness(monitor.brightness - 0.1);
}
VerticalSlider {
FilledSlider {
anchors.fill: parent
icon: `brightness_${(Math.round(value * 6) + 1)}`

View file

@ -6,20 +6,39 @@ import Quickshell.Services.Pipewire
Singleton {
id: root
readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => {
if (!node.isStream) {
if (node.isSink) acc.sinks.push(node)
else if (node.audio) acc.sources.push(node)
}
return acc
}, { sources: [], sinks: [] })
readonly property list<PwNode> sinks: nodes.sinks
readonly property list<PwNode> sources: nodes.sources
readonly property PwNode sink: Pipewire.defaultAudioSink
readonly property PwNode source: Pipewire.defaultAudioSource
readonly property bool muted: sink?.audio?.muted ?? false
readonly property bool muted: !!sink?.audio?.muted
readonly property real volume: sink?.audio?.volume ?? 0
function setVolume(volume: real): void {
function setVolume(newVolume: real): void {
if (sink?.ready && sink?.audio) {
sink.audio.muted = false;
sink.audio.volume = volume;
sink.audio.volume = newVolume;
}
}
function setAudioSink(newSink: PwNode): void {
Pipewire.preferredDefaultAudioSink = newSink
}
function setAudioSource(newSource: PwNode): void {
Pipewire.preferredDefaultAudioSource = newSource
}
PwObjectTracker {
objects: [Pipewire.defaultAudioSink, Pipewire.defaultAudioSource]
objects: [...root.sinks, ...root.sources]
}
}

View file

@ -223,6 +223,16 @@ Singleton {
return "chat";
}
function getVolumeIcon(volume: real, isMuted: bool): string {
if (isMuted)
return "no_sound";
if (volume >= 0.5)
return "volume_up";
if (volume > 0)
return "volume_down";
return "volume_mute";
}
FileView {
path: "/etc/os-release"
onLoaded: {