diff --git a/Modules/Bar/Volume.qml b/Modules/Bar/Volume.qml index 456d493..0bdc9c8 100644 --- a/Modules/Bar/Volume.qml +++ b/Modules/Bar/Volume.qml @@ -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) { diff --git a/Modules/Settings/Tabs/Audio.qml b/Modules/Settings/Tabs/Audio.qml index 3f783bb..49c4d03 100644 --- a/Modules/Settings/Tabs/Audio.qml +++ b/Modules/Settings/Tabs/Audio.qml @@ -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 - } - } - } } diff --git a/Modules/SidePanel/Cards/PowerProfilesCard.qml b/Modules/SidePanel/Cards/PowerProfilesCard.qml index 4f3c141..e5cc02a 100644 --- a/Modules/SidePanel/Cards/PowerProfilesCard.qml +++ b/Modules/SidePanel/Cards/PowerProfilesCard.qml @@ -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 diff --git a/Services/Audio.qml b/Services/Audio.qml index ea88d63..b006ae2 100644 --- a/Services/Audio.qml +++ b/Services/Audio.qml @@ -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 sinks: nodes.sinks + readonly property list 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 { diff --git a/Services/Settings.qml b/Services/Settings.qml index 301714a..3b0cbbb 100644 --- a/Services/Settings.qml +++ b/Services/Settings.qml @@ -173,7 +173,6 @@ Singleton { property JsonObject audio audio: JsonObject { - property bool volumeOverdrive: false property JsonObject audioVisualizer audioVisualizer: JsonObject { diff --git a/Services/Time.qml b/Services/Time.qml index 7a2463c..4748192 100644 --- a/Services/Time.qml +++ b/Services/Time.qml @@ -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) }