import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Io import Quickshell.Wayland import Quickshell.Services.Pipewire import "../../config" as Cfg import "../../components" ModuleChip { id: root property int outputMaxVolume: Cfg.Config.volumeOutputMax property int inputMaxVolume: Cfg.Config.volumeInputMax ConfigWriter { id: configWriter } Timer { id: saveDebounce interval: 800 repeat: false onTriggered: { configWriter.writeInts([ { key: "volumeOutputMax", value: root.outputMaxVolume }, { key: "volumeInputMax", value: root.inputMaxVolume } ]) } } onOutputMaxVolumeChanged: saveDebounce.restart() onInputMaxVolumeChanged: saveDebounce.restart() readonly property var sink: Pipewire.defaultAudioSink readonly property var source: Pipewire.defaultAudioSource PwObjectTracker { objects: root.sinkNodes.concat(root.sourceNodes) } readonly property var sinkNodes: { if (!Pipewire.nodes) return [] return Pipewire.nodes.values.filter(n => !n.isStream && n.isSink) } readonly property var sourceNodes: { if (!Pipewire.nodes) return [] return Pipewire.nodes.values.filter(n => !n.isStream && !n.isSink) } property int _outVol: 0 property int _inVol: 0 readonly property int outputVolume: { if (sink && sink.ready) { _outVol = Math.round(sink.audio.volume * 100); return _outVol } return _outVol } readonly property int inputVolume: { if (source && source.ready) { _inVol = Math.round(source.audio.volume * 100); return _inVol } return _inVol } readonly property bool outputMuted: sink && sink.ready ? sink.audio.muted : false readonly property bool inputMuted: source && source.ready ? source.audio.muted : false readonly property string outputDeviceName: sink ? (sink.description || sink.nickName || sink.name) : "No device" readonly property string inputDeviceName: source ? (source.description || source.nickName || source.name) : "No device" function setOutputVol(pct) { if (sink && sink.ready) sink.audio.volume = pct / 100 } function setInputVol(pct) { if (source && source.ready) source.audio.volume = pct / 100 } function toggleOutputMute() { if (sink && sink.ready) sink.audio.muted = !sink.audio.muted } function toggleInputMute() { if (source && source.ready) source.audio.muted = !source.audio.muted } Process { id: defaultSetter running: false property string nodeId: "0" command: ["wpctl", "set-default", nodeId] } function setDefaultDevice(node) { defaultSetter.nodeId = node.id.toString() defaultSetter.running = true } readonly property string iconSpeakerHigh: "󰕾" readonly property string iconSpeakerMed: "󰖀" readonly property string iconSpeakerLow: "󰕿" readonly property string iconSpeakerMuted: "󰖁" readonly property string iconMic: "󰍬" readonly property string iconMicMuted: "󰍭" function speakerGlyph(muted, vol) { if (muted || vol === 0) return iconSpeakerMuted if (vol > 60) return iconSpeakerHigh if (vol > 25) return iconSpeakerMed return iconSpeakerLow } width: chipLayout.implicitWidth + 15 radius: panelRadius chipColor: t.volBg chipColorHovered: t.volBgHover chipBorderColor: t.volBorder chipBorderColorHovered: t.volBorderHover chipHoverEnabled: true chipTapAction: () => volumePopup.toggle() popupOpen: volumePopup.popupOpen HoverHandler { cursorShape: Qt.PointingHandCursor onHoveredChanged: { if (hovered && Cfg.Config.volumeShakeEnabled) shakeAnim.restart() } } SequentialAnimation { id: shakeAnim NumberAnimation { target: chipLayout; property: "rotation"; to: -15; duration: 40; easing.type: Easing.OutQuad } NumberAnimation { target: chipLayout; property: "rotation"; to: 15; duration: 80; easing.type: Easing.InOutQuad } NumberAnimation { target: chipLayout; property: "rotation"; to: -10; duration: 80; easing.type: Easing.InOutQuad } NumberAnimation { target: chipLayout; property: "rotation"; to: 10; duration: 80; easing.type: Easing.InOutQuad } NumberAnimation { target: chipLayout; property: "rotation"; to: 0; duration: 40; easing.type: Easing.InQuad } } RowLayout { id: chipLayout anchors.centerIn: parent spacing: 5 transformOrigin: Item.Center Text { text: root.speakerGlyph(root.outputMuted, root.outputVolume) color: root.outputMuted ? root.t.volIconMuted : root.t.volIcon font.pixelSize: Cfg.Config.volumeIconSize font.bold: true Layout.alignment: Qt.AlignVCenter renderType: Text.NativeRendering } } StandardPopup { id: volumePopup ns: "volume" chipRoot: root cardWidth: Cfg.Config.volumePopupWidth cardColor: t.volPopupBg cardBorderColor: t.volPopupBorder cardHeight: popupCol.implicitHeight + 34 hasKeyboardFocus: true Item { id: popupCol anchors { left: parent.left; right: parent.right; top: parent.top } anchors.margins: 16 anchors.topMargin: 18 implicitWidth: popupContent.implicitWidth implicitHeight: popupContent.implicitHeight focus: true ColumnLayout { id: popupContent anchors.fill: parent spacing: 14 VolumeSection { id: outputSection Layout.fillWidth: true label: "Output" icon: root.speakerGlyph(root.outputMuted, root.outputVolume) iconColor: root.t.accent volume: root.outputVolume maxVolume: root.outputMaxVolume muted: root.outputMuted accentColor: root.t.volHandle dimColor: root.t.textDim textColor: root.t.textMain trackColor: root.t.volTrack deviceName: root.outputDeviceName nodes: root.sinkNodes currentNode: root.sink onSliderMoved: vol => root.setOutputVol(vol) onMuteToggled: root.toggleOutputMute() onDeviceSelected: node => root.setDefaultDevice(node) } Rectangle { Layout.fillWidth: true; height: 1; color: root.t.separator } VolumeSection { id: inputSection Layout.fillWidth: true label: "Input" icon: root.inputMuted ? root.iconMicMuted : root.iconMic iconColor: root.t.statusInfo volume: root.inputVolume maxVolume: root.inputMaxVolume muted: root.inputMuted accentColor: root.t.statusInfo dimColor: root.t.textDim textColor: root.t.textMain trackColor: root.t.volTrack deviceName: root.inputDeviceName nodes: root.sourceNodes currentNode: root.source onSliderMoved: vol => root.setInputVol(vol) onMuteToggled: root.toggleInputMute() onDeviceSelected: node => root.setDefaultDevice(node) } Rectangle { Layout.fillWidth: true; height: 1; color: root.t.separator } ColumnLayout { Layout.fillWidth: true spacing: 10 Text { text: "Volume Limits" color: root.t.textDim font.pixelSize: 10 font.bold: true font.capitalization: Font.AllUppercase Layout.fillWidth: true renderType: Text.NativeRendering } LimitRow { Layout.fillWidth: true icon: root.iconSpeakerHigh iconColor: root.t.accent labelText: "Output max" value: root.outputMaxVolume minValue: 10 maxValue: 300 textColor: root.t.textMain dimColor: root.t.textDim borderColor: root.t.volPopupBorder onDecrement: root.outputMaxVolume = Math.max(10, root.outputMaxVolume - 10) onIncrement: root.outputMaxVolume = Math.min(300, root.outputMaxVolume + 10) onValueSet: root.outputMaxVolume = Math.max(10, Math.min(300, val)) } LimitRow { Layout.fillWidth: true icon: root.iconMic iconColor: root.t.statusInfo labelText: "Input max" value: root.inputMaxVolume minValue: 10 maxValue: 300 textColor: root.t.textMain dimColor: root.t.textDim borderColor: root.t.volPopupBorder onDecrement: root.inputMaxVolume = Math.max(10, root.inputMaxVolume - 10) onIncrement: root.inputMaxVolume = Math.min(300, root.inputMaxVolume + 10) onValueSet: root.inputMaxVolume = Math.max(10, Math.min(300, val)) } } Item { height: 2 } } MouseArea { anchors.fill: parent z: -1 onClicked: popupCol.forceActiveFocus() } } } component VolumeSection: ColumnLayout { id: vs spacing: 8 required property string label required property string icon required property color iconColor required property int volume required property int maxVolume required property bool muted required property color accentColor required property color dimColor required property color textColor required property color trackColor required property string deviceName required property var nodes required property var currentNode signal sliderMoved(int vol) signal muteToggled() signal deviceSelected(var node) property bool pickerOpen: false RowLayout { Layout.fillWidth: true spacing: 6 Text { text: vs.icon color: vs.muted ? vs.dimColor : vs.iconColor font.pixelSize: Cfg.Config.volumeIconSize Layout.alignment: Qt.AlignVCenter Layout.preferredWidth: Cfg.Config.volumeIconSize + 4 horizontalAlignment: Text.AlignHCenter renderType: Text.NativeRendering font.bold: true } Text { text: vs.label color: vs.textColor font.pixelSize: 13 font.bold: true Layout.fillWidth: true renderType: Text.NativeRendering } TextInput { text: vs.muted ? "muted" : vs.volume + "%" color: vs.muted ? vs.dimColor : vs.textColor font.pixelSize: 12 font.bold: !vs.muted horizontalAlignment: TextInput.AlignHCenter verticalAlignment: TextInput.AlignVCenter Layout.preferredWidth: 44 renderType: Text.NativeRendering readOnly: vs.muted selectByMouse: true activeFocusOnPress: true onAccepted: { if (!vs.muted) { let val = parseInt(text.replace("%", "").trim(), 10) if (!isNaN(val)) vs.sliderMoved(Math.max(0, Math.min(vs.maxVolume, val))) text = Qt.binding(function() { return vs.muted ? "muted" : vs.volume + "%" }) } popupCol.forceActiveFocus() } onActiveFocusChanged: { if (activeFocus && !vs.muted) { text = vs.volume.toString() selectAll() } else if (!activeFocus && !vs.muted) { let val = parseInt(text.replace("%", "").trim(), 10) if (!isNaN(val)) vs.sliderMoved(Math.max(0, Math.min(vs.maxVolume, val))) text = Qt.binding(function() { return vs.muted ? "muted" : vs.volume + "%" }) } } } Rectangle { width: 22; height: 22; radius: 6 color: muteHover.hovered ? (vs.muted ? "#33ffffff" : "#22ffffff") : (vs.muted ? root.t.volMuteBg : "transparent") border.color: vs.muted ? vs.accentColor : vs.dimColor border.width: Cfg.Config.popupBorderWidth Text { anchors.centerIn: parent text: vs.muted ? "󰖁" : "󰕾" color: vs.muted ? vs.accentColor : vs.dimColor font.pixelSize: 11 font.bold: true renderType: Text.NativeRendering } HoverHandler { id: muteHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: vs.muteToggled() } } } Item { id: sliderItem Layout.fillWidth: true height: 22 Rectangle { anchors.verticalCenter: parent.verticalCenter width: parent.width; height: Cfg.Config.volumeSliderHeight; radius: 2 color: vs.trackColor Rectangle { width: Math.min(parent.width, parent.width * (vs.volume / Math.max(1, vs.maxVolume))) height: parent.height; radius: parent.radius color: vs.muted ? vs.dimColor : vs.accentColor } } Rectangle { readonly property real ratio: vs.volume / Math.max(1, vs.maxVolume) x: Math.min(sliderItem.width - width, Math.max(0, sliderItem.width * ratio - width / 2)) anchors.verticalCenter: parent.verticalCenter width: Cfg.Config.volumeHandleSize; height: Cfg.Config.volumeHandleSize; radius: width / 2 color: vs.muted ? vs.dimColor : vs.accentColor border.color: root.t.volHandleBorder border.width: Cfg.Config.popupBorderWidth } MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor onPressed: pos => sliderItem.seek(pos.x) onPositionChanged: pos => { if (pressed) sliderItem.seek(pos.x) } } WheelHandler { acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onWheel: event => { const step = 2 const delta = event.angleDelta.y > 0 ? step : -step vs.sliderMoved(Math.max(0, Math.min(vs.maxVolume, vs.volume + delta))) } } function seek(x) { let ratio = Math.max(0.0, Math.min(1.0, x / sliderItem.width)) vs.sliderMoved(Math.round(ratio * vs.maxVolume)) } } RowLayout { Layout.fillWidth: true spacing: 6 Text { text: "󰒓"; color: vs.dimColor; font.pixelSize: 11; font.bold: true; renderType: Text.NativeRendering } Text { text: vs.deviceName color: vs.textColor font.pixelSize: 11 font.bold: true elide: Text.ElideRight Layout.fillWidth: true renderType: Text.NativeRendering } Rectangle { width: 20; height: 20; radius: 5 color: chevHover.hovered ? "#22ffffff" : "transparent" border.color: vs.pickerOpen ? vs.accentColor : root.t.volPopupBorder border.width: Cfg.Config.popupBorderWidth Text { anchors.centerIn: parent text: vs.pickerOpen ? "󰅃" : "󰅀" color: vs.pickerOpen ? vs.accentColor : vs.dimColor font.pixelSize: 10 font.bold: true renderType: Text.NativeRendering } HoverHandler { id: chevHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: vs.pickerOpen = !vs.pickerOpen } } } Item { Layout.fillWidth: true Layout.preferredHeight: vs.pickerOpen ? Math.min(deviceFlickable.contentHeight, 120) : 0 opacity: vs.pickerOpen ? 1.0 : 0.0 clip: true Behavior on Layout.preferredHeight { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } Behavior on opacity { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } Flickable { id: deviceFlickable anchors.fill: parent contentWidth: width contentHeight: deviceList.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds ColumnLayout { id: deviceList width: deviceFlickable.width spacing: 2 Repeater { model: vs.nodes delegate: Rectangle { required property var modelData readonly property bool isActive: vs.currentNode !== null && modelData.id === vs.currentNode.id Layout.fillWidth: true; height: 28; radius: 7 color: isActive ? Qt.alpha(vs.accentColor, 0.18) : (rowHover.hovered ? root.t.volPickerHover : "transparent") border.color: isActive ? Qt.alpha(vs.accentColor, 0.45) : "transparent" border.width: Cfg.Config.popupBorderWidth RowLayout { anchors { fill: parent; leftMargin: 8; rightMargin: 8 } spacing: 8 Rectangle { width: 6; height: 6; radius: 3 color: isActive ? vs.accentColor : "transparent" border.color: isActive ? "transparent" : vs.dimColor border.width: Cfg.Config.popupBorderWidth } Text { text: modelData.description || modelData.nickName || modelData.name color: isActive ? vs.accentColor : vs.textColor font.pixelSize: 11; font.bold: true elide: Text.ElideRight; Layout.fillWidth: true renderType: Text.NativeRendering } } HoverHandler { id: rowHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: { vs.deviceSelected(modelData); vs.pickerOpen = false } } } } } } } } component LimitRow: RowLayout { id: lr spacing: 8 required property string icon required property color iconColor required property string labelText required property int value required property int minValue required property int maxValue required property color textColor required property color dimColor required property color borderColor signal decrement() signal increment() signal valueSet(int val) Text { text: lr.icon; color: lr.iconColor; font.pixelSize: 13; font.bold: true; renderType: Text.NativeRendering } Text { text: lr.labelText color: lr.textColor font.pixelSize: 11 font.bold: true Layout.fillWidth: true renderType: Text.NativeRendering } Rectangle { width: 22; height: 22; radius: 5 color: decHover.hovered ? "#22ffffff" : "transparent" border.color: lr.borderColor border.width: Cfg.Config.popupBorderWidth Text { anchors.centerIn: parent; text: "−" color: lr.value <= lr.minValue ? lr.dimColor : lr.textColor font.pixelSize: 14 font.bold: true renderType: Text.NativeRendering } HoverHandler { id: decHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: { if (lr.value > lr.minValue) lr.decrement() } } } TextInput { text: lr.value + "%" color: lr.textColor font.pixelSize: 12; font.bold: true horizontalAlignment: TextInput.AlignHCenter verticalAlignment: TextInput.AlignVCenter Layout.preferredWidth: 44 renderType: Text.NativeRendering selectByMouse: true activeFocusOnPress: true onAccepted: { let val = parseInt(text.replace("%", "").trim(), 10) if (!isNaN(val)) lr.valueSet(Math.max(lr.minValue, Math.min(lr.maxValue, val))) text = Qt.binding(function() { return lr.value + "%" }) popupCol.forceActiveFocus() } onActiveFocusChanged: { if (activeFocus) { text = lr.value.toString() selectAll() } else { let val = parseInt(text.replace("%", "").trim(), 10) if (!isNaN(val)) lr.valueSet(Math.max(lr.minValue, Math.min(lr.maxValue, val))) text = Qt.binding(function() { return lr.value + "%" }) } } } Rectangle { width: 22; height: 22; radius: 5 color: incHover.hovered ? "#22ffffff" : "transparent" border.color: lr.borderColor border.width: Cfg.Config.popupBorderWidth Text { anchors.centerIn: parent; text: "+" color: lr.value >= lr.maxValue ? lr.dimColor : lr.textColor font.pixelSize: 14 font.bold: true renderType: Text.NativeRendering } HoverHandler { id: incHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: { if (lr.value < lr.maxValue) lr.increment() } } } } }