- Moved all input/output logic out of UI into the Audio service
- Removed volume overdrive which does not work and is out of scope
- Reworked the audio input/output selector with radio buttons instead of
NComboBox
- Hacked a bit the main volume slider so it does not crash PW
This commit is contained in:
quadbyte 2025-08-13 15:58:08 -04:00
parent ddde4b30c4
commit b9eb31c6d4
6 changed files with 172 additions and 205 deletions

View file

@ -21,35 +21,7 @@ Item {
if (Audio.muted) { if (Audio.muted) {
return "volume_off" return "volume_off"
} }
return Audio.volume === 0 ? "volume_off" : (Audio.volume < 0.33 ? "volume_down" : "volume_up") return Audio.volume <= Number.EPSILON ? "volume_off" : (Audio.volume < 0.33 ? "volume_down" : "volume_up")
}
function getIconColor() {
return (getDisplayVolume() <= 1.0) ? Colors.textPrimary : getVolumeColor()
}
function getVolumeColor() {
if (getDisplayVolume() <= 1.0) {
return Colors.accentPrimary
}
// Indicate that the volume is over 100%
// Calculate interpolation factor (0 at 100%, 1.0 at 200%)
let factor = (getDisplayVolume() - 1.0)
// Blend between accent and warning colors
return Qt.rgba(Colors.accentPrimary.r + (Colors.error.r - Colors.accentPrimary.r) * factor,
Colors.accentPrimary.g + (Colors.error.g - Colors.accentPrimary.g) * factor,
Colors.accentPrimary.b + (Colors.error.b - Colors.accentPrimary.b) * factor, 1)
}
function getDisplayVolume() {
// If volumeOverdrive is false, clamp to 100%
if (!Settings.data.audio || !Settings.data.audio.volumeOverdrive) {
return Math.min(Audio.volume, 1.0)
}
// If volumeOverdrive is true, allow up to 200%
return Math.min(Audio.volume, 2.0)
} }
// Connection used to open the pill when volume changes // Connection used to open the pill when volume changes
@ -69,12 +41,12 @@ Item {
NPill { NPill {
id: pill id: pill
icon: getIcon() icon: getIcon()
iconCircleColor: getVolumeColor() iconCircleColor: Colors.accentPrimary
collapsedIconColor: getIconColor() collapsedIconColor: Colors.textPrimary
autoHide: true autoHide: true
text: Math.round(getDisplayVolume() * 100) + "%" text: Math.floor(Audio.volume * 100) + "%"
tooltipText: "Volume: " + Math.round( tooltipText: "Volume: " + Math.round(
getDisplayVolume() * 100) + "%\nLeft click for advanced settings.\nScroll up/down to change volume." Audio.volume * 100) + "%\nLeft click for advanced settings.\nScroll up/down to change volume."
onWheel: function (angle) { onWheel: function (angle) {
if (angle > 0) { if (angle > 0) {

View file

@ -10,6 +10,16 @@ import qs.Services
ColumnLayout { ColumnLayout {
id: root id: root
property real localVolume: Audio.volume
// Connection used to open the pill when volume changes
Connections {
target: Audio.sink?.audio ? Audio.sink?.audio : null
function onVolumeChanged() {
localVolume = Audio.volume
}
}
spacing: 0 spacing: 0
ScrollView { ScrollView {
@ -54,54 +64,55 @@ ColumnLayout {
spacing: Style.marginSmall * scaling spacing: Style.marginSmall * scaling
Layout.fillWidth: true Layout.fillWidth: true
NText { ColumnLayout {
text: "Master Volume" spacing: Style.marginTiniest * scaling
font.weight: Style.fontWeightBold
color: Colors.textPrimary
}
NText { NText {
text: "System-wide volume level" text: "Master Volume"
font.pointSize: Style.fontSizeSmall * scaling font.weight: Style.fontWeightBold
color: Colors.textSecondary color: Colors.textPrimary
wrapMode: Text.WordWrap }
Layout.fillWidth: true
}
NText {
text: "System-wide volume level"
font.pointSize: Style.fontSizeSmall * scaling
color: Colors.textSecondary
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
RowLayout { RowLayout {
// Pipewire seems a bit finicky, if we spam too many volume changes it breaks easily
// Probably because they have some quick fades in and out to avoid clipping
// We use a timer to space out the updates, to avoid lock up
Timer {
interval: 100
running: true
repeat: true
onTriggered: {
if (Math.abs(localVolume - Audio.volume) >= 0.01) {
Audio.volumeSet(localVolume)
}
}
}
NSlider { NSlider {
id: masterVolumeSlider
Layout.fillWidth: true Layout.fillWidth: true
from: 0 from: 0
to: allowOverdrive.value ? 200 : 100 to: Settings.data.audio.volumeOverdrive ? 2.0 : 1.0
value: (Audio.volume || 0) * 100 value: localVolume
stepSize: 5 stepSize: 0.01
onValueChanged: { onMoved: {
Audio.volumeSet(value / 100) localVolume = value
} }
} }
NText { NText {
text: Math.round(masterVolumeSlider.value) + "%" text: Math.floor(Audio.volume * 100) + "%"
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: Colors.textSecondary color: Colors.textSecondary
} }
} }
NToggle {
id: allowOverdrive
label: "Allow Volume Overdrive"
description: "Enable volume levels above 100% (up to 200%)"
value: Settings.data.audio ? Settings.data.audio.volumeOverdrive : false
onToggled: function (checked) {
Settings.data.audio.volumeOverdrive = checked
// If overdrive is disabled and current volume is above 100%, cap it
if (!checked && Audio.volume > 1.0) {
Audio.volumeSet(1.0)
}
}
}
} }
// Mute Toggle // Mute Toggle
@ -142,165 +153,123 @@ ColumnLayout {
Layout.bottomMargin: Style.marginSmall * scaling Layout.bottomMargin: Style.marginSmall * scaling
} }
// Output Device // -------------------------------
NComboBox { // Output Devices
id: outputDeviceCombo ButtonGroup {
label: "Output Device" id: sinks
description: "Default audio output device"
optionsKeys: outputDeviceKeys
optionsLabels: outputDeviceLabels
currentKey: Audio.sink ? Audio.sink.id.toString() : ""
onSelected: function (key) {
// Find the node by ID and set it as preferred
for (var i = 0; i < Pipewire.nodes.count; i++) {
let node = Pipewire.nodes.get(i)
if (node.id.toString() === key && node.isSink) {
Pipewire.preferredDefaultAudioSink = node
break
}
}
}
} }
// Input Device ColumnLayout {
NComboBox { spacing: Style.marginTiniest * scaling
id: inputDeviceCombo Layout.fillWidth: true
label: "Input Device" Layout.bottomMargin: Style.marginLarge * scaling
description: "Default audio input device"
optionsKeys: inputDeviceKeys NText {
optionsLabels: inputDeviceLabels text: "Output Device"
currentKey: Audio.source ? Audio.source.id.toString() : "" font.pointSize: Style.fontSizeMedium * scaling
onSelected: function (key) { font.weight: Style.fontWeightBold
// Find the node by ID and set it as preferred color: Colors.textPrimary
for (var i = 0; i < Pipewire.nodes.count; i++) { }
let node = Pipewire.nodes.get(i)
if (node.id.toString() === key && !node.isSink) { NText {
Pipewire.preferredDefaultAudioSource = node text: "Select the desired audio output device"
break font.pointSize: Style.fontSizeSmall * scaling
} color: Colors.textSecondary
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Repeater {
model: Audio.sinks
NRadioButton {
required property PwNode modelData
ButtonGroup.group: sinks
checked: Audio.sink?.id === modelData.id
onClicked: Audio.setAudioSink(modelData)
text: modelData.description
} }
} }
} }
} }
// Divider // -------------------------------
NDivider { // Input Devices
Layout.fillWidth: true ButtonGroup {
Layout.topMargin: Style.marginLarge * scaling id: sources
Layout.bottomMargin: Style.marginMedium * scaling
} }
// Audio Visualizer Category
ColumnLayout { ColumnLayout {
spacing: Style.marginSmall * scaling spacing: Style.marginTiniest * scaling
Layout.fillWidth: true Layout.fillWidth: true
Layout.bottomMargin: Style.marginLarge * scaling
NText { NText {
text: "Audio Visualizer" text: "Input Device"
font.pointSize: Style.fontSizeXL * scaling font.pointSize: Style.fontSizeMedium * scaling
font.weight: Style.fontWeightBold font.weight: Style.fontWeightBold
color: Colors.textPrimary color: Colors.textPrimary
Layout.bottomMargin: Style.marginSmall * scaling
} }
// Audio Visualizer section NText {
NComboBox { text: "Select desired audio input device"
id: audioVisualizerCombo font.pointSize: Style.fontSizeSmall * scaling
label: "Visualization Type" color: Colors.textSecondary
description: "Choose a visualization type for media playback" wrapMode: Text.WordWrap
optionsKeys: ["radial", "bars", "wave"] Layout.fillWidth: true
optionsLabels: ["Radial", "Bars", "Wave"] }
currentKey: Settings.data.audio ? Settings.data.audio.audioVisualizer.type : "radial"
onSelected: function (key) { Repeater {
if (!Settings.data.audio) { model: Audio.sources
Settings.data.audio = {} NRadioButton {
} required property PwNode modelData
if (!Settings.data.audio.audioVisualizer) { ButtonGroup.group: sources
Settings.data.audio.audioVisualizer = {} checked: Audio.source?.id === modelData.id
} onClicked: Audio.setAudioSource(modelData)
Settings.data.audio.audioVisualizer.type = key text: modelData.description
} }
} }
} }
} }
}
}
// Device list properties // Divider
property var outputDeviceKeys: ["default"] NDivider {
property var outputDeviceLabels: ["Default Output"] Layout.fillWidth: true
property var inputDeviceKeys: ["default"] Layout.topMargin: Style.marginLarge * scaling
property var inputDeviceLabels: ["Default Input"] Layout.bottomMargin: Style.marginMedium * scaling
// Bind Pipewire nodes
PwObjectTracker {
id: nodeTracker
objects: [Pipewire.nodes]
}
// Update device lists when component is completed
Component.onCompleted: {
updateDeviceLists()
}
// Timer to check if pipewire is ready and update device lists
Timer {
id: deviceUpdateTimer
interval: 100
repeat: true
running: !(Pipewire && Pipewire.ready)
onTriggered: {
if (Pipewire && Pipewire.ready) {
updateDeviceLists()
running = false
} }
}
}
// Update device lists when nodes change // Audio Visualizer Category
Connections { ColumnLayout {
target: nodeTracker spacing: Style.marginSmall * scaling
function onObjectsChanged() { Layout.fillWidth: true
updateDeviceLists()
}
}
Repeater { NText {
id: nodesRepeater text: "Audio Visualizer"
model: Pipewire.nodes font.pointSize: Style.fontSizeXL * scaling
delegate: Item { font.weight: Style.fontWeightBold
Component.onCompleted: { color: Colors.textPrimary
if (modelData && modelData.isSink && modelData.audio) { Layout.bottomMargin: Style.marginSmall * scaling
// Add to output devices }
let key = modelData.id.toString()
if (!outputDeviceKeys.includes(key)) { // Audio Visualizer section
outputDeviceKeys.push(key) NComboBox {
outputDeviceLabels.push(modelData.description || modelData.name || "Unknown Device") id: audioVisualizerCombo
} label: "Visualization Type"
} else if (modelData && !modelData.isSink && modelData.audio) { description: "Choose a visualization type for media playback"
// Add to input devices optionsKeys: ["radial", "bars", "wave"]
let key = modelData.id.toString() optionsLabels: ["Radial", "Bars", "Wave"]
if (!inputDeviceKeys.includes(key)) { currentKey: Settings.data.audio ? Settings.data.audio.audioVisualizer.type : "radial"
inputDeviceKeys.push(key) onSelected: function (key) {
inputDeviceLabels.push(modelData.description || modelData.name || "Unknown Device") if (!Settings.data.audio) {
Settings.data.audio = {}
}
if (!Settings.data.audio.audioVisualizer) {
Settings.data.audio.audioVisualizer = {}
}
Settings.data.audio.audioVisualizer.type = key
} }
} }
} }
} }
} }
function updateDeviceLists() {
if (Pipewire && Pipewire.ready) {
// Update comboboxes
if (outputDeviceCombo) {
outputDeviceCombo.optionsKeys = outputDeviceKeys
outputDeviceCombo.optionsLabels = outputDeviceLabels
}
if (inputDeviceCombo) {
inputDeviceCombo.optionsKeys = inputDeviceKeys
inputDeviceCombo.optionsLabels = inputDeviceLabels
}
}
}
} }

View file

@ -8,8 +8,24 @@ import Quickshell.Services.Pipewire
Singleton { Singleton {
id: root id: root
readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => {
if (!node.isStream) {
if (node.isSink) {
acc.sinks.push(node)
} else if (node.audio) {
acc.sources.push(node)
}
}
return acc
}, {
"sources": [],
"sinks": []
})
readonly property PwNode sink: Pipewire.defaultAudioSink readonly property PwNode sink: Pipewire.defaultAudioSink
readonly property PwNode source: Pipewire.defaultAudioSource readonly property PwNode source: Pipewire.defaultAudioSource
readonly property list<PwNode> sinks: nodes.sinks
readonly property list<PwNode> sources: nodes.sources
// Volume [0..1] is readonly from outside // Volume [0..1] is readonly from outside
readonly property alias volume: root._volume readonly property alias volume: root._volume
@ -29,15 +45,26 @@ Singleton {
} }
function volumeSet(newVolume) { function volumeSet(newVolume) {
// Clamp volume to 200%
if (sink?.ready && sink?.audio) { if (sink?.ready && sink?.audio) {
// Clamp it accordingly
sink.audio.muted = false sink.audio.muted = false
sink.audio.volume = Math.max(0, Math.min(2, newVolume)) sink.audio.volume = Math.max(0, Math.min(1, newVolume))
//console.log("[Audio] volumeSet", sink.audio.volume);
} else {
console.warn("[Audio] No sink available")
} }
} }
function setAudioSink(newSink: PwNode): void {
Pipewire.preferredDefaultAudioSink = newSink
}
function setAudioSource(newSource: PwNode): void {
Pipewire.preferredDefaultAudioSource = newSource
}
PwObjectTracker { PwObjectTracker {
objects: [Pipewire.defaultAudioSink, Pipewire.nodes] objects: [...root.sinks, ...root.sources]
} }
Connections { Connections {

View file

@ -173,7 +173,6 @@ Singleton {
property JsonObject audio property JsonObject audio
audio: JsonObject { audio: JsonObject {
property bool volumeOverdrive: false
property JsonObject audioVisualizer property JsonObject audioVisualizer
audioVisualizer: JsonObject { audioVisualizer: JsonObject {

View file

@ -40,7 +40,7 @@ Singleton {
// Returns a Unix Timestamp (in seconds) // Returns a Unix Timestamp (in seconds)
readonly property int timestamp: { readonly property int timestamp: {
return Math.floor(Date.now() / 1000) return Math.floor(date / 1000)
} }