import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Io import Quickshell.Wayland import Quickshell.Services.Pipewire import "../../config" as Cfg Rectangle { id: root property real panelRadius: Cfg.Config.radius / 2 property int borderWidth: Cfg.Config.panelBorderWidth property bool isTop: true property var targetScreen: null property int barHeight: Cfg.Config.barHeight property int barMargin: Cfg.Config.margin readonly property var t: Cfg.Config.theme property int outputMaxVolume: Cfg.Config.volumeOutputMax property int inputMaxVolume: Cfg.Config.volumeInputMax 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) 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 Layout.alignment: Qt.AlignVCenter } } PanelWindow { id: volumePopup screen: root.targetScreen visible: false color: "transparent" property bool popupOpen: false function open() { hideTimer.stop(); visible = true; popupOpen = true } function close() { popupOpen = false; hideTimer.restart() } function toggle() { popupOpen ? close() : open() } Timer { id: hideTimer interval: Cfg.Config.popupAnimDuration + 70 onTriggered: volumePopup.visible = false } anchors { top: true; bottom: true; left: true; right: true } WlrLayershell.layer: WlrLayer.Top WlrLayershell.namespace: "main-shell-volume" WlrLayershell.exclusiveZone: -1 mask: popupOpen ? null : _noInput Region { id: _noInput } MouseArea { anchors.fill: parent onClicked: volumePopup.close() } Item { id: volClipContainer readonly property int cardW: Cfg.Config.volumePopupWidth readonly property int screenPad: root.barMargin + 10 x: { let cx = root.mapToItem(null, 0, 0).x + root.width / 2 return Math.max(screenPad, Math.min(cx - cardW / 2, parent.width - cardW - screenPad)) } width: cardW anchors.top: root.isTop ? parent.top : undefined anchors.bottom: root.isTop ? undefined : parent.bottom anchors.topMargin: root.isTop ? (root.barHeight + 10) : 0 anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + 10) height: parent.height - root.barHeight - 10 clip: true 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: Translate { y: volumePopup.popupOpen ? 0 : (root.isTop ? -volCard.height : volCard.height) Behavior on y { NumberAnimation { duration: Cfg.Config.popupAnimDuration; easing.type: Easing.OutExpo } 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 layer.enabled: true 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.capitalization: Font.AllUppercase Layout.fillWidth: true } 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 } Text { text: vs.label color: vs.textColor font.pixelSize: 13 font.bold: true Layout.fillWidth: true } 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 } 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 } 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 } Text { text: vs.deviceName color: vs.textColor font.pixelSize: 11 elide: Text.ElideRight Layout.fillWidth: true } 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 } 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: isActive elide: Text.ElideRight; Layout.fillWidth: true } } 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 } Text { text: lr.labelText color: lr.textColor font.pixelSize: 11 Layout.fillWidth: true } 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 } 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 } 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 } HoverHandler { id: incHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: { if (lr.value < lr.maxValue) lr.increment() } } } } }