Files
quickdots/bar/modules/Volume.qml

566 lines
24 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
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() } }
}
}
}