quickshell: complete native PulseAudio source support
This commit is contained in:
parent
63a39ab5d0
commit
dfd494c5f1
4 changed files with 251 additions and 43 deletions
|
|
@ -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/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.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/ShellModel.qml souveraine/modules/common/ShellModel.qml
|
||||
modules/common/functions/Session.qml souveraine/modules/common/functions/Session.qml
|
||||
|
|
|
|||
158
surfaces/quickshell/modules/ii/bar/UtilButtons.qml
Normal file
158
surfaces/quickshell/modules/ii/bar/UtilButtons.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,16 +4,15 @@ import qs.modules.common.widgets
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Native PulseAudio has one verified board endpoint today: the UCM Speaker
|
||||
// route published as alsa_output.hw_0_0. ii's original dialog only renders
|
||||
// PipeWire nodes, so it presented an empty application list as though no
|
||||
// device existed. Keep the device state explicit until capture and alternate
|
||||
// routes are real PulseAudio endpoints.
|
||||
// Native PulseAudio publishes the Pixel 3 board endpoints directly: playback
|
||||
// on hw:0,0 and UCM handset capture on hw:0,1. Present both without depending
|
||||
// on PipeWire's node model.
|
||||
ColumnLayout {
|
||||
id: root
|
||||
required property bool isSink
|
||||
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
|
||||
|
||||
Rectangle {
|
||||
|
|
@ -45,7 +44,7 @@ ColumnLayout {
|
|||
color: Appearance.colors.colOnLayer2
|
||||
text: root.isSink
|
||||
? (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 {
|
||||
|
|
@ -55,7 +54,9 @@ ColumnLayout {
|
|||
color: Appearance.m3colors.m3outline
|
||||
text: root.isSink
|
||||
? 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 {
|
||||
Layout.fillWidth: true
|
||||
visible: root.isSink && root.outputAvailable
|
||||
value: Audio.sink?.audio?.volume ?? 0
|
||||
onMoved: Audio.sink.audio.volume = value
|
||||
visible: root.isSink ? root.outputAvailable : root.inputAvailable
|
||||
value: root.currentNode?.audio?.volume ?? 0
|
||||
onMoved: root.currentNode.audio.volume = value
|
||||
configuration: StyledSlider.Configuration.M
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: root.isSink && root.outputAvailable
|
||||
visible: root.isSink ? root.outputAvailable : root.inputAvailable
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: Appearance.colors.colSubtext
|
||||
text: Audio.sink?.audio?.muted
|
||||
text: root.currentNode?.audio?.muted
|
||||
? Translation.tr("Muted")
|
||||
: `${Math.round((Audio.sink?.audio?.volume ?? 0) * 100)}%`
|
||||
: `${Math.round((root.currentNode?.audio?.volume ?? 0) * 100)}%`
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ Singleton {
|
|||
id: root
|
||||
|
||||
property bool ready: false
|
||||
property bool sourceReady: false
|
||||
property bool syncing: false
|
||||
property bool autoMuted: false
|
||||
property bool micActive: false
|
||||
readonly property real hardMaxValue: 1.00
|
||||
property string audioTheme: Config.options.sounds.theme
|
||||
readonly property real value: sink.audio.volume
|
||||
|
|
@ -47,28 +49,34 @@ Singleton {
|
|||
}
|
||||
}
|
||||
|
||||
// A capture source is intentionally absent until the ADSP capture route
|
||||
// produces real samples. Stock ii consumers (e.g. QuickSliders.qml) do
|
||||
// `Audio.source.audio.volume` with no null-guard — safe against ii's own
|
||||
// PipeWire Audio, which always populates source as an object. Give this
|
||||
// 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.
|
||||
// Mirror PulseAudio's default source into the same node-shaped API used
|
||||
// for output. This is the handset UCM capture endpoint (hw:0,1), not a
|
||||
// placeholder: QS must be able to control and report the real microphone
|
||||
// even while the lower-level zero-sample fault is being diagnosed.
|
||||
property QtObject source: QtObject {
|
||||
id: sourceNode
|
||||
property string id: ""
|
||||
property string id: name
|
||||
property string name: ""
|
||||
property string description: Translation.tr("Microphone")
|
||||
property string description: Translation.tr("Internal microphone")
|
||||
property string nickname: description
|
||||
property bool isSink: false
|
||||
property var properties: ({})
|
||||
property var properties: ({ "node.name": name })
|
||||
property QtObject audio: QtObject {
|
||||
property real volume: 0
|
||||
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> inputDevices: []
|
||||
readonly property list<var> inputDevices: source.name.length > 0 ? [source] : []
|
||||
readonly property list<var> outputAppNodes: []
|
||||
readonly property list<var> inputAppNodes: []
|
||||
|
||||
|
|
@ -95,8 +103,9 @@ Singleton {
|
|||
}
|
||||
|
||||
function toggleMicMute() {
|
||||
if (source?.audio)
|
||||
source.audio.muted = !source.audio.muted
|
||||
if (!sourceReady)
|
||||
return
|
||||
setSourceMuted(!source.audio.muted)
|
||||
}
|
||||
|
||||
function incrementVolume() {
|
||||
|
|
@ -122,6 +131,21 @@ Singleton {
|
|||
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) {
|
||||
if (!node?.name)
|
||||
return
|
||||
|
|
@ -136,41 +160,65 @@ Singleton {
|
|||
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.
|
||||
Process {
|
||||
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" })
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const text = this.text
|
||||
const name = /^name=(.+)$/m.exec(text)?.[1]?.trim()
|
||||
const volume = /^volume=.*?(\d+)%/m.exec(text)?.[1]
|
||||
const muted = /^mute=Mute:\s*(yes|no)$/m.exec(text)?.[1]
|
||||
if (!name || volume === undefined || muted === undefined) {
|
||||
const sinkName = /^sink_name=(.+)$/m.exec(text)?.[1]?.trim()
|
||||
const sinkVolume = /^sink_volume=.*?(\d+)%/m.exec(text)?.[1]
|
||||
const sinkMuted = /^sink_mute=Mute:\s*(yes|no)$/m.exec(text)?.[1]
|
||||
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
|
||||
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
|
||||
sinkNode.name = name
|
||||
sinkNode.description = name.includes("hw_0_0")
|
||||
? Translation.tr("Internal speakers") : name
|
||||
sinkNode.audio.volume = Math.max(0, Math.min(root.hardMaxValue, Number(volume) / 100))
|
||||
sinkNode.audio.muted = muted === "yes"
|
||||
root.syncing = false
|
||||
root.ready = true
|
||||
if (!sourceName || sourceVolume === undefined || sourceMuted === undefined) {
|
||||
root.sourceReady = false
|
||||
root.micActive = false
|
||||
} else {
|
||||
root.syncing = true
|
||||
sourceNode.name = sourceName
|
||||
sourceNode.description = sourceName.includes("hw_0_1")
|
||||
? 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 => {
|
||||
if (exitCode !== 0)
|
||||
if (exitCode !== 0) {
|
||||
root.ready = false
|
||||
root.sourceReady = false
|
||||
root.micActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process { id: volumeProcess }
|
||||
Process { id: muteProcess }
|
||||
Process { id: sourceVolumeProcess }
|
||||
Process { id: sourceMuteProcess }
|
||||
Process { id: defaultSinkProcess }
|
||||
Process { id: defaultSourceProcess }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue