Watch
1
0
Fork
You've already forked souveraine
0

quickshell: complete native PulseAudio source support

This commit is contained in:
Fimeg 2026-07-18 15:05:17 -04:00
commit dfd494c5f1
4 changed files with 251 additions and 43 deletions

View file

@ -45,6 +45,7 @@ modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/Side
modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml
modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml souveraine/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml souveraine/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml
modules/ii/bar/UtilButtons.qml souveraine/modules/ii/bar/UtilButtons.qml
modules/common/Config.qml souveraine/modules/common/Config.qml modules/common/Config.qml souveraine/modules/common/Config.qml
modules/common/ShellModel.qml souveraine/modules/common/ShellModel.qml modules/common/ShellModel.qml souveraine/modules/common/ShellModel.qml
modules/common/functions/Session.qml souveraine/modules/common/functions/Session.qml modules/common/functions/Session.qml souveraine/modules/common/functions/Session.qml

View file

@ -0,0 +1,158 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.UPower
Item {
id: root
property bool borderless: Config.options.bar.borderless
implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2
implicitHeight: rowLayout.implicitHeight
RowLayout {
id: rowLayout
spacing: 4
anchors.centerIn: parent
Loader {
active: Config.options.bar.utilButtons.showScreenSnip
visible: Config.options.bar.utilButtons.showScreenSnip
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "region", "screenshot"])
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 1
text: "screenshot_region"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showScreenRecord
visible: Config.options.bar.utilButtons.showScreenRecord
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: Quickshell.execDetached([Directories.recordScriptPath])
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 1
text: "videocam"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showColorPicker
visible: Config.options.bar.utilButtons.showColorPicker
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: Quickshell.execDetached(["hyprpicker", "-a"])
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 1
text: "colorize"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showKeyboardToggle
visible: Config.options.bar.utilButtons.showKeyboardToggle
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: GlobalStates.oskOpen = !GlobalStates.oskOpen
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 0
text: "keyboard"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showMicToggle
visible: Config.options.bar.utilButtons.showMicToggle
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: Audio.toggleMicMute()
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 0
text: Audio.source?.audio?.muted ? "mic_off" : "mic"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showDarkModeToggle
visible: Config.options.bar.utilButtons.showDarkModeToggle
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: event => {
if (Appearance.m3colors.darkmode) {
Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode light --noswitch`])
} else {
Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode dark --noswitch`])
}
}
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 0
text: Appearance.m3colors.darkmode ? "light_mode" : "dark_mode"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showPerformanceProfileToggle
visible: Config.options.bar.utilButtons.showPerformanceProfileToggle
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: event => {
if (PowerProfiles.hasPerformanceProfile) {
switch(PowerProfiles.profile) {
case PowerProfile.PowerSaver: PowerProfiles.profile = PowerProfile.Balanced
break
case PowerProfile.Balanced: PowerProfiles.profile = PowerProfile.Performance
break
case PowerProfile.Performance: PowerProfiles.profile = PowerProfile.PowerSaver
break
}
} else {
PowerProfiles.profile = PowerProfiles.profile == PowerProfile.Balanced ? PowerProfile.PowerSaver : PowerProfile.Balanced
}
}
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 0
text: switch(PowerProfiles.profile) {
case PowerProfile.PowerSaver: return "energy_savings_leaf"
case PowerProfile.Balanced: return "airwave"
case PowerProfile.Performance: return "local_fire_department"
}
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
}
}

View file

@ -4,16 +4,15 @@ import qs.modules.common.widgets
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
// Native PulseAudio has one verified board endpoint today: the UCM Speaker // Native PulseAudio publishes the Pixel 3 board endpoints directly: playback
// route published as alsa_output.hw_0_0. ii's original dialog only renders // on hw:0,0 and UCM handset capture on hw:0,1. Present both without depending
// PipeWire nodes, so it presented an empty application list as though no // on PipeWire's node model.
// device existed. Keep the device state explicit until capture and alternate
// routes are real PulseAudio endpoints.
ColumnLayout { ColumnLayout {
id: root id: root
required property bool isSink required property bool isSink
readonly property bool outputAvailable: Audio.ready && Audio.sink?.name.length > 0 readonly property bool outputAvailable: Audio.ready && Audio.sink?.name.length > 0
readonly property bool inputAvailable: Audio.source?.audio !== undefined && Audio.source?.audio !== null readonly property bool inputAvailable: Audio.sourceReady && Audio.source?.name.length > 0
readonly property var currentNode: root.isSink ? Audio.sink : Audio.source
spacing: 16 spacing: 16
Rectangle { Rectangle {
@ -45,7 +44,7 @@ ColumnLayout {
color: Appearance.colors.colOnLayer2 color: Appearance.colors.colOnLayer2
text: root.isSink text: root.isSink
? (root.outputAvailable ? Audio.friendlyDeviceName(Audio.sink) : Translation.tr("Connecting to PulseAudio…")) ? (root.outputAvailable ? Audio.friendlyDeviceName(Audio.sink) : Translation.tr("Connecting to PulseAudio…"))
: (root.inputAvailable ? Audio.friendlyDeviceName(Audio.source) : Translation.tr("No microphone source yet")) : (root.inputAvailable ? Audio.friendlyDeviceName(Audio.source) : Translation.tr("Connecting to PulseAudio…"))
} }
StyledText { StyledText {
@ -55,7 +54,9 @@ ColumnLayout {
color: Appearance.m3colors.m3outline color: Appearance.m3colors.m3outline
text: root.isSink text: root.isSink
? Translation.tr("Default output • Pixel 3 internal stereo speakers") ? Translation.tr("Default output • Pixel 3 internal stereo speakers")
: Translation.tr("The handset DMIC route is still under capture validation") : (Audio.micActive
? Translation.tr("Default input • recording in progress")
: Translation.tr("Default input • Pixel 3 handset microphone"))
} }
} }
} }
@ -63,20 +64,20 @@ ColumnLayout {
StyledSlider { StyledSlider {
Layout.fillWidth: true Layout.fillWidth: true
visible: root.isSink && root.outputAvailable visible: root.isSink ? root.outputAvailable : root.inputAvailable
value: Audio.sink?.audio?.volume ?? 0 value: root.currentNode?.audio?.volume ?? 0
onMoved: Audio.sink.audio.volume = value onMoved: root.currentNode.audio.volume = value
configuration: StyledSlider.Configuration.M configuration: StyledSlider.Configuration.M
} }
StyledText { StyledText {
Layout.fillWidth: true Layout.fillWidth: true
visible: root.isSink && root.outputAvailable visible: root.isSink ? root.outputAvailable : root.inputAvailable
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
color: Appearance.colors.colSubtext color: Appearance.colors.colSubtext
text: Audio.sink?.audio?.muted text: root.currentNode?.audio?.muted
? Translation.tr("Muted") ? Translation.tr("Muted")
: `${Math.round((Audio.sink?.audio?.volume ?? 0) * 100)}%` : `${Math.round((root.currentNode?.audio?.volume ?? 0) * 100)}%`
} }
Item { Layout.fillHeight: true } Item { Layout.fillHeight: true }

View file

@ -18,8 +18,10 @@ Singleton {
id: root id: root
property bool ready: false property bool ready: false
property bool sourceReady: false
property bool syncing: false property bool syncing: false
property bool autoMuted: false property bool autoMuted: false
property bool micActive: false
readonly property real hardMaxValue: 1.00 readonly property real hardMaxValue: 1.00
property string audioTheme: Config.options.sounds.theme property string audioTheme: Config.options.sounds.theme
readonly property real value: sink.audio.volume readonly property real value: sink.audio.volume
@ -47,28 +49,34 @@ Singleton {
} }
} }
// A capture source is intentionally absent until the ADSP capture route // Mirror PulseAudio's default source into the same node-shaped API used
// produces real samples. Stock ii consumers (e.g. QuickSliders.qml) do // for output. This is the handset UCM capture endpoint (hw:0,1), not a
// `Audio.source.audio.volume` with no null-guard safe against ii's own // placeholder: QS must be able to control and report the real microphone
// PipeWire Audio, which always populates source as an object. Give this // even while the lower-level zero-sample fault is being diagnosed.
// a real stub (mirroring sink's shape) instead of null so those
// consumers work unmodified; mic volume just reads 0 and setting it is
// a no-op until real capture exists.
property QtObject source: QtObject { property QtObject source: QtObject {
id: sourceNode id: sourceNode
property string id: "" property string id: name
property string name: "" property string name: ""
property string description: Translation.tr("Microphone") property string description: Translation.tr("Internal microphone")
property string nickname: description property string nickname: description
property bool isSink: false property bool isSink: false
property var properties: ({}) property var properties: ({ "node.name": name })
property QtObject audio: QtObject { property QtObject audio: QtObject {
property real volume: 0 property real volume: 0
property bool muted: false property bool muted: false
onVolumeChanged: {
if (root.sourceReady && !root.syncing)
root.setSourceVolume(volume)
}
onMutedChanged: {
if (root.sourceReady && !root.syncing)
root.setSourceMuted(muted)
}
} }
} }
readonly property list<var> outputDevices: sink.name.length > 0 ? [sink] : [] readonly property list<var> outputDevices: sink.name.length > 0 ? [sink] : []
readonly property list<var> inputDevices: [] readonly property list<var> inputDevices: source.name.length > 0 ? [source] : []
readonly property list<var> outputAppNodes: [] readonly property list<var> outputAppNodes: []
readonly property list<var> inputAppNodes: [] readonly property list<var> inputAppNodes: []
@ -95,8 +103,9 @@ Singleton {
} }
function toggleMicMute() { function toggleMicMute() {
if (source?.audio) if (!sourceReady)
source.audio.muted = !source.audio.muted return
setSourceMuted(!source.audio.muted)
} }
function incrementVolume() { function incrementVolume() {
@ -122,6 +131,21 @@ Singleton {
refreshSoon.restart() refreshSoon.restart()
} }
function setSourceVolume(nextVolume) {
if (!sourceReady || !isFinite(nextVolume))
return
const bounded = Math.max(0, Math.min(hardMaxValue, Number(nextVolume)))
sourceVolumeProcess.exec(["pactl", "set-source-volume", "@DEFAULT_SOURCE@", `${Math.round(bounded * 100)}%`])
refreshSoon.restart()
}
function setSourceMuted(muted) {
if (!sourceReady)
return
sourceMuteProcess.exec(["pactl", "set-source-mute", "@DEFAULT_SOURCE@", muted ? "1" : "0"])
refreshSoon.restart()
}
function setDefaultSink(node) { function setDefaultSink(node) {
if (!node?.name) if (!node?.name)
return return
@ -136,41 +160,65 @@ Singleton {
refreshSoon.restart() refreshSoon.restart()
} }
// All three status reads must be from the same PulseAudio default sink. // Read the default sink and source in one transaction so the UI never
// mixes state from different PulseAudio generations.
// The tagged output avoids relying on locale-sensitive pactl labels. // The tagged output avoids relying on locale-sensitive pactl labels.
Process { Process {
id: statusProcess id: statusProcess
command: ["sh", "-c", "printf 'name='; pactl get-default-sink; printf 'volume='; pactl get-sink-volume @DEFAULT_SINK@; printf 'mute='; pactl get-sink-mute @DEFAULT_SINK@"] command: ["sh", "-c", "printf 'sink_name='; pactl get-default-sink; printf 'sink_volume='; pactl get-sink-volume @DEFAULT_SINK@; printf 'sink_mute='; pactl get-sink-mute @DEFAULT_SINK@; printf 'source_name='; pactl get-default-source; printf 'source_volume='; pactl get-source-volume @DEFAULT_SOURCE@; printf 'source_mute='; pactl get-source-mute @DEFAULT_SOURCE@; printf 'source_outputs='; pactl list short source-outputs | wc -l"]
environment: ({ LANG: "C", LC_ALL: "C" }) environment: ({ LANG: "C", LC_ALL: "C" })
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: { onStreamFinished: {
const text = this.text const text = this.text
const name = /^name=(.+)$/m.exec(text)?.[1]?.trim() const sinkName = /^sink_name=(.+)$/m.exec(text)?.[1]?.trim()
const volume = /^volume=.*?(\d+)%/m.exec(text)?.[1] const sinkVolume = /^sink_volume=.*?(\d+)%/m.exec(text)?.[1]
const muted = /^mute=Mute:\s*(yes|no)$/m.exec(text)?.[1] const sinkMuted = /^sink_mute=Mute:\s*(yes|no)$/m.exec(text)?.[1]
if (!name || volume === undefined || muted === undefined) { const sourceName = /^source_name=(.+)$/m.exec(text)?.[1]?.trim()
const sourceVolume = /^source_volume=.*?(\d+)%/m.exec(text)?.[1]
const sourceMuted = /^source_mute=Mute:\s*(yes|no)$/m.exec(text)?.[1]
const sourceOutputs = /^source_outputs=(\d+)$/m.exec(text)?.[1]
if (!sinkName || sinkVolume === undefined || sinkMuted === undefined) {
root.ready = false root.ready = false
return } else {
root.syncing = true
sinkNode.name = sinkName
sinkNode.description = sinkName.includes("hw_0_0")
? Translation.tr("Internal speakers") : sinkName
sinkNode.audio.volume = Math.max(0, Math.min(root.hardMaxValue, Number(sinkVolume) / 100))
sinkNode.audio.muted = sinkMuted === "yes"
root.syncing = false
root.ready = true
} }
root.syncing = true if (!sourceName || sourceVolume === undefined || sourceMuted === undefined) {
sinkNode.name = name root.sourceReady = false
sinkNode.description = name.includes("hw_0_0") root.micActive = false
? Translation.tr("Internal speakers") : name } else {
sinkNode.audio.volume = Math.max(0, Math.min(root.hardMaxValue, Number(volume) / 100)) root.syncing = true
sinkNode.audio.muted = muted === "yes" sourceNode.name = sourceName
root.syncing = false sourceNode.description = sourceName.includes("hw_0_1")
root.ready = true ? Translation.tr("Internal microphone") : sourceName
sourceNode.audio.volume = Math.max(0, Math.min(root.hardMaxValue, Number(sourceVolume) / 100))
sourceNode.audio.muted = sourceMuted === "yes"
root.syncing = false
root.sourceReady = true
root.micActive = Number(sourceOutputs ?? 0) > 0
}
} }
} }
onExited: exitCode => { onExited: exitCode => {
if (exitCode !== 0) if (exitCode !== 0) {
root.ready = false root.ready = false
root.sourceReady = false
root.micActive = false
}
} }
} }
Process { id: volumeProcess } Process { id: volumeProcess }
Process { id: muteProcess } Process { id: muteProcess }
Process { id: sourceVolumeProcess }
Process { id: sourceMuteProcess }
Process { id: defaultSinkProcess } Process { id: defaultSinkProcess }
Process { id: defaultSourceProcess } Process { id: defaultSourceProcess }