Files
quickdots/bar/modules/Volume.qml
2026-04-24 02:14:35 +00:00

531 lines
22 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
TapHandler { onTapped: volumePopup.toggle() }
RowLayout {
id: chipLayout
anchors.centerIn: parent
spacing: 5
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: 1
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: 1
}
MouseArea {
anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onPressed: pos => sliderItem.seek(pos.x)
onPositionChanged: pos => { if (pressed) sliderItem.seek(pos.x) }
}
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: 1
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: 1
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: 1
}
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: 1
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: 1
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() } }
}
}
}