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 readonly property string configPath: Quickshell.env("HOME") + "/.config/quickshell/config/config.qml" Process { id: volConfigWriter running: false onExited: (code) => { if (code !== 0) console.warn("[Volume] Failed to patch config.qml (exit " + code + ")") } } Timer { id: saveDebounce interval: 800 repeat: false onTriggered: { const outMax = Math.round(root.outputMaxVolume).toString() const inMax = Math.round(root.inputMaxVolume).toString() const cfgPath = root.configPath const sh = "sed -i" + " -e 's/\\(property int[ ]\\+volumeOutputMax:[ ]\\+\\)[0-9.]\\+/\\1" + outMax + "/'" + " -e 's/\\(property int[ ]\\+volumeInputMax:[ ]\\+\\)[0-9.]\\+/\\1" + inMax + "/'" + " " + JSON.stringify(cfgPath) volConfigWriter.command = ["/bin/sh", "-c", sh] volConfigWriter.running = true } } onOutputMaxVolumeChanged: { if (root.outputMaxVolume >= 10) saveDebounce.restart() } onInputMaxVolumeChanged: { if (root.inputMaxVolume >= 10) 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 height: Cfg.Config.moduleHeight radius: panelRadius color: hoverHandler.hovered ? t.volBgHover : t.volBg border.color: (hoverHandler.hovered || volumePopup.popupOpen) ? t.volBorderHover : t.volBorder border.width: borderWidth Behavior on color { ColorAnimation { duration: 150 } } Behavior on border.color { ColorAnimation { duration: 150 } } HoverHandler { id: hoverHandler cursorShape: Qt.PointingHandCursor onHoveredChanged: { if (hovered && Cfg.Config.volumeShakeEnabled) shakeAnim.restart() } } TapHandler { onTapped: volumePopup.toggle() } 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 } } PopupPanel { id: volumePopup ns: "volume" chipRoot: root cardWidth: Cfg.Config.volumePopupWidth Rectangle { id: volCard width: parent.width height: popupCol.implicitHeight + 32 anchors.top: root.isTop ? parent.top : undefined anchors.bottom: root.isTop ? undefined : parent.bottom transform: Scale { yScale: volumePopup.popupOpen ? 1 : 0 origin.y: root.isTop ? 0 : volCard.height Behavior on yScale { NumberAnimation { duration: Cfg.Config.popupAnimDuration easing.type: Easing.OutBack } enabled: volumePopup.visible } } opacity: volumePopup.popupOpen ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: 200 } } radius: Cfg.Config.popupRadius color: root.t.volPopupBg border.color: root.t.volPopupBorder border.width: Cfg.Config.popupBorderWidth MouseArea { anchors.fill: parent; propagateComposedEvents: false } ColumnLayout { id: popupCol anchors { left: parent.left; right: parent.right; top: parent.top } anchors.margins: 16 anchors.topMargin: 18 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) } 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) } } Item { height: 2 } } } } 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 } Text { text: vs.muted ? "muted" : vs.volume + "%" color: vs.muted ? vs.dimColor : vs.textColor font.pixelSize: 12 font.bold: !vs.muted horizontalAlignment: Text.AlignHCenter Layout.preferredWidth: 44 renderType: Text.NativeRendering } 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() 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() } } } Text { text: lr.value + "%" color: lr.textColor font.pixelSize: 12; font.bold: true horizontalAlignment: Text.AlignHCenter Layout.preferredWidth: 44 renderType: Text.NativeRendering } 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() } } } } }