Audio
- 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:
parent
ddde4b30c4
commit
b9eb31c6d4
6 changed files with 172 additions and 205 deletions
|
|
@ -21,35 +21,7 @@ Item {
|
|||
if (Audio.muted) {
|
||||
return "volume_off"
|
||||
}
|
||||
return Audio.volume === 0 ? "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)
|
||||
return Audio.volume <= Number.EPSILON ? "volume_off" : (Audio.volume < 0.33 ? "volume_down" : "volume_up")
|
||||
}
|
||||
|
||||
// Connection used to open the pill when volume changes
|
||||
|
|
@ -69,12 +41,12 @@ Item {
|
|||
NPill {
|
||||
id: pill
|
||||
icon: getIcon()
|
||||
iconCircleColor: getVolumeColor()
|
||||
collapsedIconColor: getIconColor()
|
||||
iconCircleColor: Colors.accentPrimary
|
||||
collapsedIconColor: Colors.textPrimary
|
||||
autoHide: true
|
||||
text: Math.round(getDisplayVolume() * 100) + "%"
|
||||
text: Math.floor(Audio.volume * 100) + "%"
|
||||
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) {
|
||||
if (angle > 0) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ import qs.Services
|
|||
ColumnLayout {
|
||||
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
|
||||
|
||||
ScrollView {
|
||||
|
|
@ -54,54 +64,55 @@ ColumnLayout {
|
|||
spacing: Style.marginSmall * scaling
|
||||
Layout.fillWidth: true
|
||||
|
||||
NText {
|
||||
text: "Master Volume"
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.textPrimary
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: Style.marginTiniest * scaling
|
||||
|
||||
NText {
|
||||
text: "System-wide volume level"
|
||||
font.pointSize: Style.fontSizeSmall * scaling
|
||||
color: Colors.textSecondary
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
NText {
|
||||
text: "Master Volume"
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.textPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: "System-wide volume level"
|
||||
font.pointSize: Style.fontSizeSmall * scaling
|
||||
color: Colors.textSecondary
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
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 {
|
||||
id: masterVolumeSlider
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: allowOverdrive.value ? 200 : 100
|
||||
value: (Audio.volume || 0) * 100
|
||||
stepSize: 5
|
||||
onValueChanged: {
|
||||
Audio.volumeSet(value / 100)
|
||||
to: Settings.data.audio.volumeOverdrive ? 2.0 : 1.0
|
||||
value: localVolume
|
||||
stepSize: 0.01
|
||||
onMoved: {
|
||||
localVolume = value
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: Math.round(masterVolumeSlider.value) + "%"
|
||||
text: Math.floor(Audio.volume * 100) + "%"
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
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
|
||||
|
|
@ -142,165 +153,123 @@ ColumnLayout {
|
|||
Layout.bottomMargin: Style.marginSmall * scaling
|
||||
}
|
||||
|
||||
// Output Device
|
||||
NComboBox {
|
||||
id: outputDeviceCombo
|
||||
label: "Output Device"
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
// -------------------------------
|
||||
// Output Devices
|
||||
ButtonGroup {
|
||||
id: sinks
|
||||
}
|
||||
|
||||
// Input Device
|
||||
NComboBox {
|
||||
id: inputDeviceCombo
|
||||
label: "Input Device"
|
||||
description: "Default audio input device"
|
||||
optionsKeys: inputDeviceKeys
|
||||
optionsLabels: inputDeviceLabels
|
||||
currentKey: Audio.source ? Audio.source.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.preferredDefaultAudioSource = node
|
||||
break
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: Style.marginTiniest * scaling
|
||||
Layout.fillWidth: true
|
||||
Layout.bottomMargin: Style.marginLarge * scaling
|
||||
|
||||
NText {
|
||||
text: "Output Device"
|
||||
font.pointSize: Style.fontSizeMedium * scaling
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.textPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: "Select the desired audio output device"
|
||||
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 {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Style.marginLarge * scaling
|
||||
Layout.bottomMargin: Style.marginMedium * scaling
|
||||
// -------------------------------
|
||||
// Input Devices
|
||||
ButtonGroup {
|
||||
id: sources
|
||||
}
|
||||
|
||||
// Audio Visualizer Category
|
||||
ColumnLayout {
|
||||
spacing: Style.marginSmall * scaling
|
||||
spacing: Style.marginTiniest * scaling
|
||||
Layout.fillWidth: true
|
||||
Layout.bottomMargin: Style.marginLarge * scaling
|
||||
|
||||
NText {
|
||||
text: "Audio Visualizer"
|
||||
font.pointSize: Style.fontSizeXL * scaling
|
||||
text: "Input Device"
|
||||
font.pointSize: Style.fontSizeMedium * scaling
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.textPrimary
|
||||
Layout.bottomMargin: Style.marginSmall * scaling
|
||||
}
|
||||
|
||||
// Audio Visualizer section
|
||||
NComboBox {
|
||||
id: audioVisualizerCombo
|
||||
label: "Visualization Type"
|
||||
description: "Choose a visualization type for media playback"
|
||||
optionsKeys: ["radial", "bars", "wave"]
|
||||
optionsLabels: ["Radial", "Bars", "Wave"]
|
||||
currentKey: Settings.data.audio ? Settings.data.audio.audioVisualizer.type : "radial"
|
||||
onSelected: function (key) {
|
||||
if (!Settings.data.audio) {
|
||||
Settings.data.audio = {}
|
||||
}
|
||||
if (!Settings.data.audio.audioVisualizer) {
|
||||
Settings.data.audio.audioVisualizer = {}
|
||||
}
|
||||
Settings.data.audio.audioVisualizer.type = key
|
||||
NText {
|
||||
text: "Select desired audio input device"
|
||||
font.pointSize: Style.fontSizeSmall * scaling
|
||||
color: Colors.textSecondary
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Audio.sources
|
||||
NRadioButton {
|
||||
required property PwNode modelData
|
||||
ButtonGroup.group: sources
|
||||
checked: Audio.source?.id === modelData.id
|
||||
onClicked: Audio.setAudioSource(modelData)
|
||||
text: modelData.description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device list properties
|
||||
property var outputDeviceKeys: ["default"]
|
||||
property var outputDeviceLabels: ["Default Output"]
|
||||
property var inputDeviceKeys: ["default"]
|
||||
property var inputDeviceLabels: ["Default Input"]
|
||||
|
||||
// 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
|
||||
// Divider
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Style.marginLarge * scaling
|
||||
Layout.bottomMargin: Style.marginMedium * scaling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update device lists when nodes change
|
||||
Connections {
|
||||
target: nodeTracker
|
||||
function onObjectsChanged() {
|
||||
updateDeviceLists()
|
||||
}
|
||||
}
|
||||
// Audio Visualizer Category
|
||||
ColumnLayout {
|
||||
spacing: Style.marginSmall * scaling
|
||||
Layout.fillWidth: true
|
||||
|
||||
Repeater {
|
||||
id: nodesRepeater
|
||||
model: Pipewire.nodes
|
||||
delegate: Item {
|
||||
Component.onCompleted: {
|
||||
if (modelData && modelData.isSink && modelData.audio) {
|
||||
// Add to output devices
|
||||
let key = modelData.id.toString()
|
||||
if (!outputDeviceKeys.includes(key)) {
|
||||
outputDeviceKeys.push(key)
|
||||
outputDeviceLabels.push(modelData.description || modelData.name || "Unknown Device")
|
||||
}
|
||||
} else if (modelData && !modelData.isSink && modelData.audio) {
|
||||
// Add to input devices
|
||||
let key = modelData.id.toString()
|
||||
if (!inputDeviceKeys.includes(key)) {
|
||||
inputDeviceKeys.push(key)
|
||||
inputDeviceLabels.push(modelData.description || modelData.name || "Unknown Device")
|
||||
NText {
|
||||
text: "Audio Visualizer"
|
||||
font.pointSize: Style.fontSizeXL * scaling
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.textPrimary
|
||||
Layout.bottomMargin: Style.marginSmall * scaling
|
||||
}
|
||||
|
||||
// Audio Visualizer section
|
||||
NComboBox {
|
||||
id: audioVisualizerCombo
|
||||
label: "Visualization Type"
|
||||
description: "Choose a visualization type for media playback"
|
||||
optionsKeys: ["radial", "bars", "wave"]
|
||||
optionsLabels: ["Radial", "Bars", "Wave"]
|
||||
currentKey: Settings.data.audio ? Settings.data.audio.audioVisualizer.type : "radial"
|
||||
onSelected: function (key) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ NBox {
|
|||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
implicitHeight: powerRow.implicitHeight + Style.marginMedium * 2 * scaling
|
||||
|
||||
|
||||
// PowerProfiles service
|
||||
property var powerProfiles: PowerProfiles
|
||||
|
||||
|
||||
RowLayout {
|
||||
id: powerRow
|
||||
anchors.fill: parent
|
||||
|
|
|
|||
|
|
@ -8,8 +8,24 @@ import Quickshell.Services.Pipewire
|
|||
Singleton {
|
||||
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 source: Pipewire.defaultAudioSource
|
||||
readonly property list<PwNode> sinks: nodes.sinks
|
||||
readonly property list<PwNode> sources: nodes.sources
|
||||
|
||||
// Volume [0..1] is readonly from outside
|
||||
readonly property alias volume: root._volume
|
||||
|
|
@ -29,15 +45,26 @@ Singleton {
|
|||
}
|
||||
|
||||
function volumeSet(newVolume) {
|
||||
// Clamp volume to 200%
|
||||
if (sink?.ready && sink?.audio) {
|
||||
// Clamp it accordingly
|
||||
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 {
|
||||
objects: [Pipewire.defaultAudioSink, Pipewire.nodes]
|
||||
objects: [...root.sinks, ...root.sources]
|
||||
}
|
||||
|
||||
Connections {
|
||||
|
|
|
|||
|
|
@ -173,7 +173,6 @@ Singleton {
|
|||
property JsonObject audio
|
||||
|
||||
audio: JsonObject {
|
||||
property bool volumeOverdrive: false
|
||||
property JsonObject audioVisualizer
|
||||
|
||||
audioVisualizer: JsonObject {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ Singleton {
|
|||
|
||||
// Returns a Unix Timestamp (in seconds)
|
||||
readonly property int timestamp: {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
return Math.floor(date / 1000)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue