531 lines
22 KiB
QML
531 lines
22 KiB
QML
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() } }
|
||
}
|
||
}
|
||
}
|