diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index 652b610..1c866f2 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -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 diff --git a/surfaces/quickshell/modules/ii/bar/UtilButtons.qml b/surfaces/quickshell/modules/ii/bar/UtilButtons.qml new file mode 100644 index 0000000..7ba0095 --- /dev/null +++ b/surfaces/quickshell/modules/ii/bar/UtilButtons.qml @@ -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 + } + } + } + } +} diff --git a/surfaces/quickshell/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml b/surfaces/quickshell/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml index 43cf581..e999b57 100644 --- a/surfaces/quickshell/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml +++ b/surfaces/quickshell/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml @@ -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 } diff --git a/surfaces/quickshell/services/Audio.qml b/surfaces/quickshell/services/Audio.qml index 5625a68..48e32e0 100644 --- a/surfaces/quickshell/services/Audio.qml +++ b/surfaces/quickshell/services/Audio.qml @@ -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 outputDevices: sink.name.length > 0 ? [sink] : [] - readonly property list inputDevices: [] + readonly property list inputDevices: source.name.length > 0 ? [source] : [] readonly property list outputAppNodes: [] readonly property list 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 }