Give Sound the whole story, and keep the buttons inside the card
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -75,6 +75,21 @@ ShellRoot {
|
||||
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
|
||||
}
|
||||
|
||||
// A throwaway two-stream group for the clamp cases. The shared fixture
|
||||
// above is mutated by the volume and mute tests, and a clamp assertion that
|
||||
// depended on which of those ran first would be worthless.
|
||||
function clampFixture(): var {
|
||||
return {
|
||||
key: "clamp",
|
||||
label: "Clamp",
|
||||
icon: "audio-x-generic-symbolic",
|
||||
nodes: [
|
||||
{ audio: { volume: 0.1, muted: true } },
|
||||
{ audio: { volume: 0.1, muted: true } }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "application-volume-test"
|
||||
|
||||
@@ -115,6 +130,31 @@ ShellRoot {
|
||||
});
|
||||
}
|
||||
|
||||
// Over-amplification is a preference, so the ceiling is an argument
|
||||
// rather than a constant -- this file stays Settings-free on purpose.
|
||||
function clampVolume(): string {
|
||||
const overAmp = clampFixture();
|
||||
const overAmpChanged = AudioStreams.setVolume(overAmp, 1.4, 1.5);
|
||||
const ceiling = clampFixture();
|
||||
AudioStreams.setVolume(ceiling, 2.5, 1.5);
|
||||
const defaultMax = clampFixture();
|
||||
AudioStreams.setVolume(defaultMax, 1.4);
|
||||
const floor = clampFixture();
|
||||
AudioStreams.setVolume(floor, -0.5, 1.5);
|
||||
const nonNumeric = clampFixture();
|
||||
const nonNumericChanged = AudioStreams.setVolume(nonNumeric, "loud", 1.5);
|
||||
return JSON.stringify({
|
||||
overAmpChanged,
|
||||
overAmp: overAmp.nodes.map(node => node.audio.volume),
|
||||
overAmpMuted: overAmp.nodes.map(node => node.audio.muted),
|
||||
ceiling: ceiling.nodes.map(node => node.audio.volume),
|
||||
defaultMax: defaultMax.nodes.map(node => node.audio.volume),
|
||||
floor: floor.nodes.map(node => node.audio.volume),
|
||||
nonNumericChanged,
|
||||
nonNumeric: nonNumeric.nodes.map(node => node.audio.volume)
|
||||
});
|
||||
}
|
||||
|
||||
function serviceSummary(): string {
|
||||
const applications = AudioDevices.applications;
|
||||
return JSON.stringify({
|
||||
|
||||
@@ -1487,6 +1487,24 @@ Singleton {
|
||||
detail: "Maximum notification banners shown at once"
|
||||
},
|
||||
|
||||
// ── Sound ───────────────────────────────────────────────────────────
|
||||
// The two audio preferences that are Panama's own. Everything else on
|
||||
// the Sound page is live PipeWire or a GNOME desktop key, and belongs
|
||||
// to the system rather than to this file.
|
||||
//
|
||||
// Both are read by scripts/panama-osd as well as by the shell, so the
|
||||
// volume keys behave the same whether the panel is open or not.
|
||||
{
|
||||
key: "overAmplification", type: "bool", def: false, group: "sound",
|
||||
label: "Over-amplification",
|
||||
detail: "Lets the volume slider go to 150% — louder, at the cost of distortion on some hardware"
|
||||
},
|
||||
{
|
||||
key: "volumeChangeBlip", type: "bool", def: true, group: "sound",
|
||||
label: "Volume-change blip",
|
||||
detail: "A short click each time the volume keys move the output level"
|
||||
},
|
||||
|
||||
// ── Capture ─────────────────────────────────────────────────────────
|
||||
// Directories and encoder arguments are enums rather than free text:
|
||||
// both are handed to a recorder process, and an arbitrary string there
|
||||
|
||||
@@ -83,6 +83,13 @@ Singleton {
|
||||
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
||||
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
||||
|
||||
// ── Sound ───────────────────────────────────────────────────────────────
|
||||
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
|
||||
// on means 1.5. Every slider and the volume keys read the same switch, so
|
||||
// the ceiling cannot differ depending on where you changed the volume from.
|
||||
readonly property bool overAmplification: DesktopPreferences.get("overAmplification")
|
||||
readonly property bool volumeChangeBlip: DesktopPreferences.get("volumeChangeBlip")
|
||||
|
||||
// ── Focus ──────────────────────────────────────────────────────────────
|
||||
// One deliberate default rather than a preset picker: quick settings and
|
||||
// the keyboard shortcut should start a useful session in one action.
|
||||
|
||||
@@ -25,6 +25,10 @@ Item {
|
||||
readonly property real volume: root.node && root.node.audio ? root.node.audio.volume : 0
|
||||
readonly property bool muted: root.node && root.node.audio ? root.node.audio.muted : false
|
||||
|
||||
// Over-amplification extends the output to 150%. The input is never
|
||||
// extended: a microphone above 100% is gain on noise, not loudness.
|
||||
readonly property real maximum: root.output && Settings.overAmplification ? 1.5 : 1
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
}
|
||||
@@ -57,21 +61,46 @@ Item {
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
id: slider
|
||||
anchors.left: muteButton.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.right: chevron.left
|
||||
anchors.rightMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: root.muted ? 0 : root.volume
|
||||
value: root.muted ? 0 : root.volume / root.maximum
|
||||
onMoved: v => {
|
||||
if (!root.node || !root.node.audio)
|
||||
return;
|
||||
// Nudging the slider is also how you unmute, same as GNOME.
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = v;
|
||||
root.node.audio.volume = v * root.maximum;
|
||||
}
|
||||
}
|
||||
|
||||
// Everything above 100%, marked over the track so the loud end of the
|
||||
// slider is visibly the loud end rather than more of the same.
|
||||
Rectangle {
|
||||
anchors.right: slider.right
|
||||
anchors.verticalCenter: slider.verticalCenter
|
||||
width: root.maximum > 1 ? slider.width * (1 - 1 / root.maximum) : 0
|
||||
height: 10
|
||||
radius: 2
|
||||
visible: root.maximum > 1
|
||||
color: Theme.alpha(Theme.warn, 0.30)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: slider.verticalCenter
|
||||
x: slider.x + slider.width / root.maximum - 1
|
||||
width: 2
|
||||
height: 16
|
||||
radius: 1
|
||||
visible: root.maximum > 1
|
||||
color: Theme.alpha(Theme.warn, 0.7)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: chevron
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
// One application's own level, and where it plays.
|
||||
//
|
||||
// The destination is part of the row because it is part of the same question:
|
||||
// an application is too loud, or it is coming out of the wrong speakers, and
|
||||
// both are answered here rather than in a mixer somebody else ships. Picking a
|
||||
// device moves every stream the application owns; "System default" hands it
|
||||
// back to following whatever the Output card says.
|
||||
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
@@ -11,11 +19,25 @@ Rectangle {
|
||||
|
||||
required property var application
|
||||
|
||||
// Open only while a destination is being chosen. The row is otherwise one
|
||||
// line tall, because the list of sinks is longer than the mixer usually is.
|
||||
property bool picking: false
|
||||
|
||||
readonly property real volume: AudioDevices.applicationVolume(root.application)
|
||||
readonly property bool muted: AudioDevices.applicationMuted(root.application)
|
||||
|
||||
// "" means the application follows the default sink rather than naming one.
|
||||
readonly property string sinkName: String(SoundRouting.currentSinkFor(root.application) ?? "")
|
||||
readonly property string sinkLabel: {
|
||||
if (root.sinkName === "")
|
||||
return "System default";
|
||||
const node = AudioDevices.outputs.find(candidate =>
|
||||
String(candidate?.name ?? "") === root.sinkName);
|
||||
return node ? AudioDevices.label(node) : root.sinkName;
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: 78
|
||||
implicitHeight: 78 + (root.picking ? sinkMenu.implicitHeight + 6 : 0)
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.fg, 0.025)
|
||||
border.width: 1
|
||||
@@ -37,10 +59,20 @@ Rectangle {
|
||||
tint: Theme.fg
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: sinkButton
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: applicationIcon.verticalCenter
|
||||
text: root.sinkLabel + (root.picking ? " ▴" : " ▾")
|
||||
enabled: !SoundRouting.busy
|
||||
onClicked: root.picking = !root.picking
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: applicationIcon.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.right: sinkButton.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: applicationIcon.verticalCenter
|
||||
spacing: 1
|
||||
@@ -69,8 +101,8 @@ Rectangle {
|
||||
id: muteButton
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 6
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 42
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.muted || root.volume <= 0.001
|
||||
@@ -103,4 +135,85 @@ Rectangle {
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
|
||||
// The destinations: following the default, or any present output by name.
|
||||
Column {
|
||||
id: sinkMenu
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 78
|
||||
visible: root.picking
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: [{
|
||||
name: "",
|
||||
label: "System default"
|
||||
}].concat(AudioDevices.outputs.map(node => ({
|
||||
name: String(node?.name ?? ""),
|
||||
label: AudioDevices.label(node)
|
||||
})))
|
||||
|
||||
Rectangle {
|
||||
id: option
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool chosen: String(option.modelData.name) === root.sinkName
|
||||
|
||||
width: sinkMenu.width
|
||||
implicitHeight: 30
|
||||
radius: 8
|
||||
color: optionHover.hovered
|
||||
? Theme.alpha(Theme.fg, 0.07)
|
||||
: "transparent"
|
||||
border.width: 0
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: mark.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(option.modelData.label)
|
||||
color: option.chosen ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
id: mark
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: option.chosen
|
||||
text: "✓"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: optionHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
if (option.modelData.name === "")
|
||||
SoundRouting.routeToDefault(root.application);
|
||||
else
|
||||
SoundRouting.moveApplication(root.application, String(option.modelData.name));
|
||||
root.picking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ SettingRow {
|
||||
detail: "Adjust the left and right channels"
|
||||
controlWidth: 270
|
||||
visible: root.available
|
||||
divider: false
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// A pill beside a device name: how the device is attached, or why it is not
|
||||
// here at all.
|
||||
//
|
||||
// Nine outputs named after their chipsets look alike in a list. Which of them
|
||||
// arrives over the network, over Bluetooth, or is simply away in its case is
|
||||
// the fact that tells them apart, so it is said next to the name rather than
|
||||
// left to be inferred from a subtitle.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
// The token the badge is drawn from -- cyan for network devices, the accent
|
||||
// for Bluetooth, muted for a device that is not present.
|
||||
property color tone: Theme.accent
|
||||
|
||||
implicitWidth: label.implicitWidth + 14
|
||||
implicitHeight: 17
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(root.tone, 0.10)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(root.tone, 0.28)
|
||||
|
||||
Text {
|
||||
id: label
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: root.text
|
||||
color: root.tone
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Which applications are listening right now, and a way to shut each of them
|
||||
// up without hunting through their own preferences.
|
||||
//
|
||||
// The row is absent rather than empty when nothing is listening: "no
|
||||
// applications are using the microphone" is a sentence nobody needs to read on
|
||||
// a page they opened to change an output device.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.modules.quicksettings
|
||||
import qs.services
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
readonly property var users: AudioDevices.captureApplications ?? []
|
||||
|
||||
label: "Using the microphone"
|
||||
detail: "Applications listening right now"
|
||||
// A Column skips invisible children, so an empty row costs no space.
|
||||
visible: root.users.length > 0
|
||||
controlWidth: Math.max(120, chips.implicitWidth + 8)
|
||||
|
||||
// Mute state is audio state: untracked nodes report false and swallow the
|
||||
// write that would have muted them.
|
||||
PwObjectTracker {
|
||||
objects: root.users.reduce((all, application) =>
|
||||
all.concat(application?.nodes ?? []), [])
|
||||
}
|
||||
|
||||
Row {
|
||||
id: chips
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: root.users
|
||||
|
||||
Rectangle {
|
||||
id: chip
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool muted: AudioDevices.applicationMuted(chip.modelData)
|
||||
|
||||
implicitWidth: name.implicitWidth + muteButton.width + 20
|
||||
implicitHeight: 28
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: name
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(chip.modelData?.label ?? "")
|
||||
color: chip.muted ? Theme.fgMuted : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
iconSize: 14
|
||||
icon: chip.muted
|
||||
? "microphone-sensitivity-muted-symbolic"
|
||||
: "audio-input-microphone-symbolic"
|
||||
iconFallback: "audio-input-microphone-symbolic"
|
||||
tint: chip.muted ? Theme.danger : Theme.fgDim
|
||||
onClicked: AudioDevices.setApplicationMuted(chip.modelData, !chip.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// One chip per channel the selected output actually has.
|
||||
//
|
||||
// "Test" used to play a single front-centre sample, which answers "is this the
|
||||
// right device" and nothing else. A surround receiver wired to the wrong socket
|
||||
// passes that test. Each chip plays its own channel's sample, so the answer to
|
||||
// "is the rear left speaker actually rear left" is one click rather than a
|
||||
// guess -- and the chip lights while its sample is playing, so a silent speaker
|
||||
// is visibly a silent speaker rather than a click that did nothing.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
property var node: null
|
||||
|
||||
// [{ name, label }], ordered, from the node's own channel map.
|
||||
readonly property var channels: root.node ? SoundTest.channelsFor(root.node) : []
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 10
|
||||
|
||||
// Chips share the width evenly while they fit and wrap when they do not, so
|
||||
// stereo gets two wide chips and 7.1 gets rows of whatever fits.
|
||||
readonly property int columns: Math.max(1,
|
||||
Math.min(root.channels.length, Math.floor(root.width / 150)))
|
||||
readonly property real chipWidth: root.columns > 0
|
||||
? (root.width - root.spacing * (root.columns - 1)) / root.columns
|
||||
: root.width
|
||||
|
||||
Repeater {
|
||||
model: root.channels
|
||||
|
||||
Rectangle {
|
||||
id: chip
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string channelName: String(chip.modelData.name ?? "")
|
||||
readonly property bool playing: SoundTest.playingChannel === chip.channelName
|
||||
|
||||
width: root.chipWidth
|
||||
implicitHeight: 44
|
||||
radius: Theme.cardRadius
|
||||
color: chip.playing
|
||||
? Theme.alpha(Theme.accent, 0.20)
|
||||
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.08 : 0.04)
|
||||
border.width: 1
|
||||
border.color: chip.playing
|
||||
? Theme.alpha(Theme.accent, 0.60)
|
||||
: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: "Play " + String(chip.modelData.label ?? "")
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 16
|
||||
icon: "audio-volume-high-symbolic"
|
||||
iconFallback: "audio-speakers-symbolic"
|
||||
tint: chip.playing ? Theme.fg : Theme.fgDim
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(chip.modelData.label ?? "")
|
||||
color: chip.playing ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: chipHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: SoundTest.playChannel(root.node, chip.channelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
// The devices of one direction, as a list you choose from.
|
||||
//
|
||||
// The list tells the whole story, which means it also has to account for the
|
||||
// device that is not here: a session whose default is a pair of AirPods in
|
||||
// their case used to show no trace of them, so the output that audio will
|
||||
// return to the moment they connect looked like something Panama had
|
||||
// forgotten. SoundDefaults knows the configured name, and a configured name
|
||||
// with no node behind it renders last, dimmed and inert -- a ghost row.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
@@ -10,8 +19,14 @@ Column {
|
||||
readonly property var nodes: AudioDevices.nodes(root.output)
|
||||
readonly property var current: AudioDevices.current(root.output)
|
||||
|
||||
// { name, label } for the configured default that is not present, or null
|
||||
// when everything the session chose is here. The service owns both the
|
||||
// comparison and the naming: an absent device has no description to borrow,
|
||||
// and its stored name is all there is to read it from.
|
||||
readonly property var absent: SoundDefaults.absent(root.output)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 8
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.nodes
|
||||
@@ -26,9 +41,17 @@ Column {
|
||||
}
|
||||
}
|
||||
|
||||
SoundDeviceRow {
|
||||
width: root.width
|
||||
visible: !!root.absent
|
||||
output: root.output
|
||||
ghost: true
|
||||
ghostLabel: root.absent ? String(root.absent.label) : ""
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.nodes.length === 0
|
||||
visible: root.nodes.length === 0 && !root.absent
|
||||
text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
// One audio device: what it is, how it is attached, and whether it is the one
|
||||
// in use.
|
||||
//
|
||||
// Trailing controls are measured, never guessed. The old layout subtracted the
|
||||
// Use button's width from the name column and forgot both the Test button
|
||||
// beside it and the spacing between them, so every output row overflowed its
|
||||
// card by exactly that much. The name column now ends where the trailing Row
|
||||
// begins, and that Row is as wide as whatever it happens to contain.
|
||||
//
|
||||
// Selecting is the row itself rather than a button, which is what freed the
|
||||
// width in the first place: the radio on the right says which device is in use,
|
||||
// and clicking anywhere that is not a control makes it this one.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
@@ -6,18 +19,35 @@ import qs.widgets
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
Rectangle {
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var node
|
||||
property var node: null
|
||||
property bool output: true
|
||||
property bool selected: false
|
||||
property bool divider: true
|
||||
|
||||
implicitHeight: root.output || !root.selected ? 88 : 105
|
||||
radius: Theme.cardRadius
|
||||
color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025)
|
||||
border.width: 1
|
||||
border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07)
|
||||
// A default that is configured but not present -- AirPods in their case.
|
||||
// It renders as itself, dimmed and inert, rather than as an absence that
|
||||
// leaves the list looking like it forgot.
|
||||
property bool ghost: false
|
||||
property string ghostLabel: ""
|
||||
|
||||
// The channel strip is per row and stays open until it is dismissed, so
|
||||
// walking a surround set is one click per speaker rather than two.
|
||||
property bool testOpen: false
|
||||
|
||||
readonly property real volume: root.node?.audio?.volume ?? 0
|
||||
readonly property bool muted: root.node?.audio?.muted ?? false
|
||||
readonly property var properties: root.node?.properties ?? ({})
|
||||
|
||||
readonly property string deviceApi: String(root.properties["device.api"] ?? "")
|
||||
readonly property bool airplay: root.deviceApi === "raop"
|
||||
readonly property bool bluetooth: root.deviceApi === "bluez5"
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: content.implicitHeight
|
||||
opacity: root.ghost ? 0.55 : 1
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
@@ -29,154 +59,303 @@ Rectangle {
|
||||
enabled: !root.output && root.selected
|
||||
}
|
||||
|
||||
readonly property real volume: root.node?.audio?.volume ?? 0
|
||||
readonly property bool muted: root.node?.audio?.muted ?? false
|
||||
|
||||
function iconName(): string {
|
||||
if (!root.output)
|
||||
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
|
||||
if (root.airplay)
|
||||
return "network-wireless-symbolic";
|
||||
if (root.bluetooth)
|
||||
return "bluetooth-active-symbolic";
|
||||
if (root.muted || root.volume <= 0.001)
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (root.volume < 0.34)
|
||||
return "audio-volume-low-symbolic";
|
||||
if (root.volume < 0.67)
|
||||
return "audio-volume-medium-symbolic";
|
||||
return "audio-volume-high-symbolic";
|
||||
return "audio-speakers-symbolic";
|
||||
}
|
||||
|
||||
Row {
|
||||
id: heading
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 10
|
||||
anchors.topMargin: 8
|
||||
height: 28
|
||||
spacing: 10
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 20
|
||||
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||
iconFallback: "audio-card-symbolic"
|
||||
tint: root.selected ? Theme.accent : Theme.fg
|
||||
}
|
||||
|
||||
Column {
|
||||
width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: AudioDevices.label(root.node)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: root.selected ? Font.DemiBold : Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node)
|
||||
text: root.node.nickname ?? ""
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
// Outputs only. Testing an input would mean recording and playing
|
||||
// it back, which is a different thing than this button implies.
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.output
|
||||
text: "Test"
|
||||
onClicked: SoundTest.play(root.node)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: useButton
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.selected ? 78 : 64
|
||||
text: root.selected ? "Default" : "Use"
|
||||
enabled: !root.selected
|
||||
onClicked: AudioDevices.select(root.output, root.node)
|
||||
}
|
||||
}
|
||||
// 48000 -> "48 kHz", 44100 -> "44.1 kHz". Absent on plenty of nodes, which
|
||||
// is why every part of the subtitle is dropped rather than defaulted.
|
||||
function rateLabel(): string {
|
||||
const rate = Number(root.properties["audio.rate"] ?? 0);
|
||||
if (!Number.isFinite(rate) || rate <= 0)
|
||||
return "";
|
||||
const khz = rate / 1000;
|
||||
return (khz % 1 === 0 ? String(khz) : khz.toFixed(1)) + " kHz";
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.top: heading.bottom
|
||||
anchors.topMargin: 7
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.iconName()
|
||||
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
|
||||
onClicked: {
|
||||
if (root.node?.audio)
|
||||
root.node.audio.muted = !root.node.audio.muted;
|
||||
}
|
||||
// "S24_32LE" -> "24-bit", "F32LE" -> "32-bit float".
|
||||
function formatLabel(): string {
|
||||
const format = String(root.properties["audio.format"] ?? "");
|
||||
const bits = format.match(/(\d+)/);
|
||||
if (!bits)
|
||||
return "";
|
||||
return bits[1] + "-bit" + (format.startsWith("F") ? " float" : "");
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
id: volumeSlider
|
||||
anchors.left: muteButton.right
|
||||
anchors.leftMargin: 7
|
||||
anchors.right: volumeText.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
value: root.muted ? 0 : root.volume
|
||||
onMoved: value => {
|
||||
if (!root.node?.audio)
|
||||
return;
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = value;
|
||||
}
|
||||
function channelLabel(): string {
|
||||
const channels = root.node?.audio?.channels?.length ?? 0;
|
||||
if (channels === 1)
|
||||
return "Mono";
|
||||
if (channels === 2)
|
||||
return "Stereo";
|
||||
return channels > 2 ? channels + " channels" : "";
|
||||
}
|
||||
|
||||
Text {
|
||||
id: volumeText
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
width: 38
|
||||
text: Math.round(root.volume * 100) + "%"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
readonly property string subtitle: {
|
||||
if (root.ghost)
|
||||
return root.output
|
||||
? "Your preferred output — audio will move here when they connect"
|
||||
: "Your preferred input — Panama will listen here when it connects";
|
||||
const parts = [root.channelLabel(), root.rateLabel(), root.formatLabel()]
|
||||
.filter(part => part !== "");
|
||||
if (parts.length > 0)
|
||||
return parts.join(" · ");
|
||||
const nickname = String(root.node?.nickname ?? "");
|
||||
return nickname !== "" && nickname !== AudioDevices.label(root.node) ? nickname : "";
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: volumeSlider.left
|
||||
anchors.right: volumeText.right
|
||||
anchors.top: muteButton.bottom
|
||||
anchors.topMargin: 5
|
||||
height: 5
|
||||
radius: height / 2
|
||||
visible: !root.output && root.selected
|
||||
color: Theme.alpha(Theme.fg, 0.10)
|
||||
Column {
|
||||
id: content
|
||||
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
|
||||
radius: parent.radius
|
||||
color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary
|
||||
id: body
|
||||
|
||||
width: parent.width
|
||||
implicitHeight: 58
|
||||
radius: Theme.cardRadius
|
||||
color: rowHover.hovered && !root.ghost
|
||||
? Theme.alpha(Theme.fg, 0.05)
|
||||
: "transparent"
|
||||
border.width: 0
|
||||
|
||||
Rectangle {
|
||||
id: iconTile
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 34
|
||||
height: 34
|
||||
radius: 10
|
||||
color: root.selected
|
||||
? Theme.alpha(Theme.accent, 0.16)
|
||||
: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: 0
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
size: 18
|
||||
icon: root.iconName()
|
||||
iconFallback: root.output ? "audio-card-symbolic" : "audio-input-microphone-symbolic"
|
||||
tint: root.selected ? Theme.accent : Theme.fg
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: trailing
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
|
||||
// The live level of the source being listened to. Native and
|
||||
// event-driven: it repaints when a peak arrives and never on a
|
||||
// timer, and it exists only for the selected input.
|
||||
Rectangle {
|
||||
id: meter
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !root.output && root.selected && !root.ghost
|
||||
width: Math.min(190, Math.max(90, root.width * 0.26))
|
||||
height: 8
|
||||
radius: 4
|
||||
clip: true
|
||||
color: Theme.alpha(Theme.fg, 0.09)
|
||||
border.width: 0
|
||||
|
||||
Item {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
|
||||
clip: true
|
||||
|
||||
// Full-width gradient behind a clipped window, so a
|
||||
// given level is always the same colour rather than a
|
||||
// squashed copy of the whole ramp.
|
||||
Rectangle {
|
||||
width: meter.width
|
||||
height: parent.height
|
||||
radius: meter.radius
|
||||
border.width: 0
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.ok }
|
||||
GradientStop { position: 0.82; color: Theme.warn }
|
||||
GradientStop { position: 1.0; color: Theme.danger }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mute belongs to the device in use, next to the level it
|
||||
// silences. Scrolling it nudges that device's own volume, the
|
||||
// same gesture the speaker icon carries everywhere else.
|
||||
IconButton {
|
||||
id: muteButton
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.selected && !root.ghost
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.muted
|
||||
? (root.output ? "audio-volume-muted-symbolic" : "microphone-sensitivity-muted-symbolic")
|
||||
: (root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic")
|
||||
iconFallback: "audio-volume-high-symbolic"
|
||||
tint: root.muted ? Theme.danger : Theme.fgDim
|
||||
onClicked: {
|
||||
if (root.node?.audio)
|
||||
root.node.audio.muted = !root.node.audio.muted;
|
||||
}
|
||||
|
||||
WheelHandler {
|
||||
onWheel: event => {
|
||||
if (!root.node?.audio)
|
||||
return;
|
||||
const step = event.angleDelta.y > 0 ? 0.05 : -0.05;
|
||||
const value = Math.max(0, Math.min(1, root.volume + step));
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.output && root.selected && !root.ghost
|
||||
text: root.testOpen ? "Done" : "Test"
|
||||
onClicked: root.testOpen = !root.testOpen
|
||||
}
|
||||
|
||||
// Which device is in use. A radio rather than a button, because
|
||||
// choosing one is choosing all the others away.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 9
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: root.selected ? Theme.accent : Theme.fgMuted
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
visible: root.selected
|
||||
color: Theme.accent
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: iconTile.right
|
||||
anchors.leftMargin: 13
|
||||
anchors.right: trailing.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Row {
|
||||
id: nameLine
|
||||
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
// The name takes what the badges leave and elides into it,
|
||||
// so a long device name never pushes its own badge out of
|
||||
// the row.
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.min(implicitWidth, Math.max(0, nameLine.width
|
||||
- (badges.width > 0 ? badges.width + nameLine.spacing : 0)))
|
||||
text: root.ghost ? root.ghostLabel : AudioDevices.label(root.node)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: root.selected ? Font.DemiBold : Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
id: badges
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
|
||||
SoundBadge {
|
||||
visible: root.airplay && !root.ghost
|
||||
text: "AirPlay"
|
||||
tone: Theme.cyan
|
||||
}
|
||||
|
||||
SoundBadge {
|
||||
visible: root.bluetooth && !root.ghost
|
||||
text: "Bluetooth"
|
||||
tone: Theme.accent
|
||||
}
|
||||
|
||||
SoundBadge {
|
||||
visible: root.ghost
|
||||
text: "Returns when connected"
|
||||
tone: Theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.subtitle !== ""
|
||||
text: root.subtitle
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: rowHover
|
||||
enabled: !root.ghost && !root.selected
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: !root.ghost && !root.selected
|
||||
onTapped: AudioDevices.select(root.output, root.node)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
SoundChannelStrip {
|
||||
width: parent.width
|
||||
visible: root.testOpen && root.output && root.selected && !root.ghost
|
||||
node: root.node
|
||||
topPadding: 4
|
||||
bottomPadding: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,163 @@
|
||||
// Sound.
|
||||
//
|
||||
// One sectioned page, top to bottom: what plays, what listens, what each
|
||||
// application is doing, what the machine says when it wants attention, and the
|
||||
// card profiles underneath all of it. There are no tabs and nothing to apply --
|
||||
// every control here writes to PipeWire or to a desktop key that other
|
||||
// applications already honour, and lands the moment it is moved.
|
||||
//
|
||||
// The page used to end in a button that opened GNOME's Sound panel for device
|
||||
// profiles. It does not any more: SoundCards reads and writes them natively, so
|
||||
// there is nothing left on this subject that Panama hands to someone else.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Sound"
|
||||
lede: "Live PipeWire output, input, and device selection."
|
||||
lede: "Live PipeWire — every change lands immediately, nothing to apply."
|
||||
|
||||
readonly property var currentOutput: AudioDevices.current(true)
|
||||
readonly property var currentInput: AudioDevices.current(false)
|
||||
|
||||
// Over-amplification is the whole range, not a second slider: the maximum
|
||||
// moves and the region past 100% is marked.
|
||||
readonly property real volumeMaximum: Settings.overAmplification ? 1.5 : 1
|
||||
|
||||
// The three services that read the machine rather than the graph do it once
|
||||
// per page open, the way the Displays page refreshes hardware brightness.
|
||||
Component.onCompleted: {
|
||||
SoundFeedback.refresh();
|
||||
SoundDefaults.refresh();
|
||||
SoundCards.refresh();
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Output"
|
||||
subtitle: AudioDevices.current(true)?.description ?? "No output device"
|
||||
subtitle: root.currentOutput
|
||||
? AudioDevices.label(root.currentOutput)
|
||||
: "No output device"
|
||||
|
||||
SoundDeviceList {
|
||||
width: parent.width
|
||||
output: true
|
||||
}
|
||||
|
||||
SoundVolumeRow {
|
||||
label: "Volume"
|
||||
node: root.currentOutput
|
||||
maximum: root.volumeMaximum
|
||||
}
|
||||
|
||||
AudioBalance {
|
||||
width: parent.width
|
||||
node: AudioDevices.current(true)
|
||||
node: root.currentOutput
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
setting: "overAmplification"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Input"
|
||||
subtitle: AudioDevices.current(false)?.description ?? "No input device"
|
||||
subtitle: root.currentInput
|
||||
? AudioDevices.label(root.currentInput)
|
||||
: "No input device"
|
||||
|
||||
SoundDeviceList {
|
||||
width: parent.width
|
||||
output: false
|
||||
}
|
||||
|
||||
SoundVolumeRow {
|
||||
label: "Input volume"
|
||||
node: root.currentInput
|
||||
}
|
||||
|
||||
// Recording and playing back is the only honest microphone test: the
|
||||
// level meter above says something is arriving, not that it is you.
|
||||
ActionRow {
|
||||
label: "Test your microphone"
|
||||
detail: "Record a few seconds and play it back through the selected output"
|
||||
divider: captureRow.visible
|
||||
action: {
|
||||
if (SoundTest.micTestState === "recording")
|
||||
return "Recording…";
|
||||
if (SoundTest.micTestState === "playing")
|
||||
return "Playing back…";
|
||||
return "Record & play back";
|
||||
}
|
||||
enabled: SoundTest.micTestState === "idle" && !!root.currentInput
|
||||
onTriggered: SoundTest.startMicTest(root.currentInput, root.currentOutput)
|
||||
}
|
||||
|
||||
SoundCaptureRow {
|
||||
id: captureRow
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Applications"
|
||||
subtitle: "Control each application currently playing through PipeWire."
|
||||
subtitle: SoundRouting.lastError !== ""
|
||||
? SoundRouting.lastError
|
||||
: "Each application's own level, and where it plays. Applications playing sound appear here on their own."
|
||||
|
||||
ApplicationMixer {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
// ── What the machine says when it wants attention ───────────────────────
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Alerts & feedback"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.7
|
||||
topPadding: 6
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Sound feedback"
|
||||
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
||||
title: "Alerts"
|
||||
subtitle: SoundFeedback.lastError
|
||||
|
||||
SoundThemeRow {}
|
||||
|
||||
SettingRow {
|
||||
label: "Event sounds"
|
||||
detail: "Play alerts and interface event sounds"
|
||||
controlWidth: 42
|
||||
detail: "Interface sounds from GTK and GNOME applications, and Panama's notification chime"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.fill: parent
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: SoundFeedback.eventSounds
|
||||
enabled: !SoundFeedback.busy
|
||||
onToggled: checked => SoundFeedback.setEventSounds(checked)
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
setting: "volumeChangeBlip"
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Input feedback"
|
||||
detail: "Play sounds for supported typing and input events"
|
||||
controlWidth: 42
|
||||
detail: "Key clicks and input event sounds in GTK applications"
|
||||
controlWidth: 48
|
||||
divider: false
|
||||
|
||||
SettingsToggle {
|
||||
anchors.fill: parent
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: SoundFeedback.inputFeedback
|
||||
enabled: !SoundFeedback.busy
|
||||
onToggled: checked => SoundFeedback.setInputFeedback(checked)
|
||||
@@ -72,15 +165,56 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Advanced sound"
|
||||
// ── The hardware underneath the devices ─────────────────────────────────
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Advanced"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.7
|
||||
topPadding: 6
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Device profiles"
|
||||
detail: "Open Fedora's complete device profile panel"
|
||||
divider: false
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("sound")
|
||||
SettingsCard {
|
||||
title: "Device profiles"
|
||||
subtitle: SoundCards.lastError !== ""
|
||||
? SoundCards.lastError
|
||||
: "What each sound card is configured to do. Ports that are not physically connected are named, not hidden."
|
||||
|
||||
Repeater {
|
||||
model: SoundCards.cards ?? []
|
||||
|
||||
OptionPickerRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: String(modelData.description ?? modelData.name ?? "")
|
||||
detail: String(modelData.portHint ?? "")
|
||||
enabled: !SoundCards.busy
|
||||
divider: index < (SoundCards.cards?.length ?? 0) - 1
|
||||
current: modelData.activeProfile
|
||||
options: (modelData.profiles ?? []).map(profile => ({
|
||||
value: profile.name,
|
||||
label: String(profile.description ?? profile.name),
|
||||
detail: profile.available === false ? "Not available right now" : ""
|
||||
}))
|
||||
onPicked: value => SoundCards.setProfile(String(modelData.name), value)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: (SoundCards.cards?.length ?? 0) === 0
|
||||
text: SoundCards.busy ? "Reading device profiles…" : "No sound cards reported"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
topPadding: 12
|
||||
bottomPadding: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// The alert sound, chosen from the sound themes installed on the machine.
|
||||
//
|
||||
// A theme name means nothing on paper -- "freedesktop" and "Yaru" are two words
|
||||
// that sound like nothing -- so the row carries a Preview beside the value and
|
||||
// plays the theme that is set right now. It is the same shape as PickerRow,
|
||||
// which cannot take a second trailing control.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property bool expanded: false
|
||||
property bool divider: true
|
||||
|
||||
// [{ name, directory }] -- the display name from index.theme, and the
|
||||
// directory name, which is what the gsettings key actually stores.
|
||||
readonly property var themes: SoundFeedback.themes ?? []
|
||||
readonly property string current: String(SoundFeedback.soundTheme ?? "")
|
||||
|
||||
readonly property var currentTheme:
|
||||
root.themes.find(theme => String(theme.directory ?? theme.name) === root.current) ?? null
|
||||
readonly property string currentLabel: root.currentTheme
|
||||
? String(root.currentTheme.name ?? root.currentTheme.directory)
|
||||
: root.current
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
SettingRow {
|
||||
id: headline
|
||||
|
||||
width: parent.width
|
||||
label: "Alert sound"
|
||||
detail: "Played by notifications — Panama's own included — and by applications that ask for attention"
|
||||
activatable: root.themes.length > 0
|
||||
divider: root.divider && !root.expanded
|
||||
controlWidth: Math.max(210, trailingControls.implicitWidth + 8)
|
||||
onActivated: root.expanded = !root.expanded
|
||||
|
||||
Row {
|
||||
id: trailingControls
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Preview"
|
||||
enabled: !SoundFeedback.busy
|
||||
onClicked: SoundFeedback.previewAlert()
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.currentLabel
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.themes.length > 0
|
||||
text: root.expanded ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.expanded
|
||||
|
||||
Repeater {
|
||||
model: root.themes
|
||||
|
||||
TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string directory: String(modelData.directory ?? modelData.name)
|
||||
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? modelData.directory)
|
||||
value: directory === root.current ? "Current" : ""
|
||||
controlWidth: 90
|
||||
divider: index < root.themes.length - 1
|
||||
activatable: directory !== root.current
|
||||
onActivated: {
|
||||
SoundFeedback.setSoundTheme(directory);
|
||||
root.expanded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// The level of the device a card is about, written straight through to
|
||||
// PipeWire -- there is nothing to apply, so there is nothing to commit and
|
||||
// nothing to debounce.
|
||||
//
|
||||
// The range is a property rather than a constant because over-amplification
|
||||
// extends it to 150%. The track keeps its full length either way, so the same
|
||||
// slider position always means the same level; the region past 100% is marked
|
||||
// in the warning tone, because that is the part that can distort.
|
||||
//
|
||||
// Not a SettingRow: the control stacks below the label in a narrow window
|
||||
// exactly as SliderRow and GradientSliderRow do, which SettingRow's fixed
|
||||
// trailing column cannot.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var node: null
|
||||
property string label: "Volume"
|
||||
property string detail: ""
|
||||
property bool divider: true
|
||||
property real maximum: 1
|
||||
|
||||
readonly property bool available: !!root.node?.audio
|
||||
readonly property real volume: root.node?.audio?.volume ?? 0
|
||||
readonly property bool muted: root.node?.audio?.muted ?? false
|
||||
readonly property bool amplified: root.volume > 1.001
|
||||
|
||||
readonly property bool inline: root.width >= 520
|
||||
readonly property int controlSpan: 300
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: root.inline
|
||||
? Math.max(56, copy.implicitHeight + 20)
|
||||
: copy.implicitHeight + 32 + 30
|
||||
opacity: root.available ? 1 : 0.45
|
||||
|
||||
// Reading or writing audio state on an untracked node silently returns
|
||||
// zero, so the row binds the node it speaks for even though the device list
|
||||
// above it is already tracking the same object.
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
}
|
||||
|
||||
function setVolume(ratio: real): void {
|
||||
if (!root.node?.audio)
|
||||
return;
|
||||
const value = Math.max(0, Math.min(root.maximum, ratio * root.maximum));
|
||||
// Moving the slider is also how you unmute, same as GNOME.
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = value;
|
||||
}
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
|
||||
x: 0
|
||||
y: root.inline ? (root.height - height) / 2 : 10
|
||||
width: root.inline ? root.width - root.controlSpan - 20 : root.width
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.label
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.detail !== ""
|
||||
text: root.detail
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: control
|
||||
|
||||
width: root.inline ? root.controlSpan : root.width
|
||||
height: 32
|
||||
x: root.inline ? root.width - width : 0
|
||||
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
|
||||
|
||||
ValueSlider {
|
||||
id: slider
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: readout.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: root.available
|
||||
value: root.muted ? 0 : root.volume / root.maximum
|
||||
onMoved: ratio => root.setVolume(ratio)
|
||||
}
|
||||
|
||||
// Everything above 100%. Drawn over the track rather than into it, so
|
||||
// the slider itself stays the one the rest of the shell uses.
|
||||
Rectangle {
|
||||
anchors.right: slider.right
|
||||
anchors.verticalCenter: slider.verticalCenter
|
||||
width: root.maximum > 1 ? slider.width * (1 - 1 / root.maximum) : 0
|
||||
height: 10
|
||||
radius: 2
|
||||
visible: root.maximum > 1
|
||||
color: Theme.alpha(Theme.warn, 0.30)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: slider.verticalCenter
|
||||
x: slider.x + slider.width / root.maximum - 1
|
||||
width: 2
|
||||
height: 16
|
||||
radius: 1
|
||||
visible: root.maximum > 1
|
||||
color: Theme.alpha(Theme.warn, 0.7)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Text {
|
||||
id: readout
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 46
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: Math.round(root.volume * 100) + "%"
|
||||
color: root.amplified ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider
|
||||
color: Theme.alpha(Theme.fg, 0.065)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ PasswordField 1.0 PasswordField.qml
|
||||
AudioBalance 1.0 AudioBalance.qml
|
||||
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||
SoundBadge 1.0 SoundBadge.qml
|
||||
SoundChannelStrip 1.0 SoundChannelStrip.qml
|
||||
SoundVolumeRow 1.0 SoundVolumeRow.qml
|
||||
SoundThemeRow 1.0 SoundThemeRow.qml
|
||||
SoundCaptureRow 1.0 SoundCaptureRow.qml
|
||||
ApplicationMixer 1.0 ApplicationMixer.qml
|
||||
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
|
||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||
|
||||
@@ -3,6 +3,54 @@
|
||||
set -u
|
||||
|
||||
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly PANAMA_OSD_BLIP_DEFAULT="/usr/share/sounds/freedesktop/stereo/audio-volume-change.oga"
|
||||
|
||||
# ── Panama's own preferences ────────────────────────────────────────────────
|
||||
#
|
||||
# These run as keybinds, so the shell may not be up and there is no IPC to ask.
|
||||
# settings.json is read straight off disk instead, and every failure -- no file
|
||||
# on a fresh install, a file written before the key existed, a file that is not
|
||||
# JSON at all -- means the schema default rather than a broken volume key.
|
||||
settings_file() {
|
||||
printf '%s/panama/settings.json\n' "${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
}
|
||||
|
||||
setting_bool() {
|
||||
local key="$1" fallback="$2" file value
|
||||
file="$(settings_file)"
|
||||
[[ -r $file ]] || { printf '%s\n' "$fallback"; return 0; }
|
||||
command -v jq >/dev/null 2>&1 || { printf '%s\n' "$fallback"; return 0; }
|
||||
value="$(jq -r --arg key "$key" '
|
||||
if type == "object" and (.[$key] | type) == "boolean" then .[$key] else empty end
|
||||
' "$file" 2>/dev/null)" || value=""
|
||||
[[ $value == true || $value == false ]] || value="$fallback"
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
# wpctl clamps to 1.0 by default, and it clamps the *result* -- so the ceiling
|
||||
# has to be passed on the way down as well. Without it, stepping down from 130%
|
||||
# would snap to 100% instead of 124%, which reads as the slider jumping on its
|
||||
# own. The microphone never gets this: gain past 100% on a capture device buys
|
||||
# noise, not signal, and the Sound page's input slider stays 0-100 to match.
|
||||
volume_limit() {
|
||||
if [[ $(setting_bool overAmplification false) == true ]]; then
|
||||
printf '1.5\n'
|
||||
else
|
||||
printf '1\n'
|
||||
fi
|
||||
}
|
||||
|
||||
# The click that says the volume moved. Backgrounded and never waited on: it is
|
||||
# feedback about something that has already happened, so it must not sit between
|
||||
# the key press and the OSD. A distribution without the freedesktop sound theme
|
||||
# has no file to play, which is a silent desktop rather than a broken key.
|
||||
play_blip() {
|
||||
local sound="${PANAMA_OSD_BLIP_SOUND:-$PANAMA_OSD_BLIP_DEFAULT}"
|
||||
[[ $(setting_bool volumeChangeBlip true) == true ]] || return 0
|
||||
[[ -f $sound ]] || return 0
|
||||
pw-play "$sound" >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
}
|
||||
|
||||
strict_delivery() {
|
||||
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
|
||||
@@ -48,13 +96,15 @@ show_volume() {
|
||||
}
|
||||
|
||||
adjust_volume() {
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@" limit
|
||||
limit="$(volume_limit)"
|
||||
case "$action" in
|
||||
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume "$target" "${step}%-" || return ;;
|
||||
up) wpctl set-volume -l "$limit" "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume -l "$limit" "$target" "${step}%-" || return ;;
|
||||
toggle) wpctl set-mute "$target" toggle || return ;;
|
||||
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
play_blip
|
||||
show_volume "$target" volume
|
||||
}
|
||||
|
||||
@@ -62,7 +112,7 @@ adjust_microphone() {
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@"
|
||||
case "$action" in
|
||||
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume "$target" "${step}%-" || return ;;
|
||||
down) wpctl set-volume -l 1 "$target" "${step}%-" || return ;;
|
||||
toggle) wpctl set-mute "$target" toggle || return ;;
|
||||
*) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
@@ -27,6 +27,16 @@ Singleton {
|
||||
readonly property var applications: AudioStreams.group(
|
||||
root.playbackStreams, PwNodeType.AudioOutStream)
|
||||
|
||||
// The capture side of the same story: which applications currently hold the
|
||||
// microphone open. Same grouping rules as playback, so an application that
|
||||
// records on three streams reads as one entry with one mute.
|
||||
readonly property var captureStreams: Pipewire.nodes.values.filter(node =>
|
||||
node.ready && node.audio
|
||||
&& (node.type & PwNodeType.AudioInStream) === PwNodeType.AudioInStream)
|
||||
|
||||
readonly property var captureApplications: AudioStreams.group(
|
||||
root.captureStreams, PwNodeType.AudioInStream)
|
||||
|
||||
function nodes(output: bool): var {
|
||||
return output ? root.outputs : root.inputs;
|
||||
}
|
||||
@@ -58,8 +68,11 @@ Singleton {
|
||||
return AudioStreams.muted(application);
|
||||
}
|
||||
|
||||
function setApplicationVolume(application: var, value: real): bool {
|
||||
return AudioStreams.setVolume(application, value);
|
||||
// `max` is the clamp ceiling; omitting it means 1, and anything that is not
|
||||
// a positive number is treated as omitted. Callers that honor the
|
||||
// over-amplification setting pass 1.5.
|
||||
function setApplicationVolume(application: var, value: real, max: real): bool {
|
||||
return AudioStreams.setVolume(application, value, max);
|
||||
}
|
||||
|
||||
function setApplicationMuted(application: var, muted: bool): bool {
|
||||
|
||||
@@ -54,9 +54,16 @@ function muted(application) {
|
||||
return nodes.length > 0 && nodes.every(node => node.audio.muted === true);
|
||||
}
|
||||
|
||||
function setVolume(application, value) {
|
||||
const next = Math.max(0, Math.min(1, Number(value)));
|
||||
if (!Number.isFinite(next)) return false;
|
||||
// `max` is the ceiling the value is clamped to, defaulting to 1. It is a
|
||||
// parameter rather than a constant so over-amplification (1.5) stays a caller's
|
||||
// decision: this file is deliberately free of any Settings dependency so it can
|
||||
// be reasoned about -- and tested -- as plain JavaScript.
|
||||
function setVolume(application, value, max) {
|
||||
const requested = Number(value);
|
||||
if (!Number.isFinite(requested)) return false;
|
||||
const ceiling = Number(max);
|
||||
const bound = Number.isFinite(ceiling) && ceiling > 0 ? ceiling : 1;
|
||||
const next = Math.max(0, Math.min(bound, requested));
|
||||
const nodes = audioNodes(application);
|
||||
for (const node of nodes) {
|
||||
node.audio.muted = false;
|
||||
|
||||
@@ -15,6 +15,7 @@ pragma Singleton
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Notifications
|
||||
import QtQuick
|
||||
import qs.config
|
||||
@@ -232,6 +233,7 @@ Singleton {
|
||||
// because FocusModes.allows is false whenever no mode is active.
|
||||
if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
|
||||
root.popups = [notification].concat(root.popups);
|
||||
root.playBell(notification);
|
||||
} else if (notification.transient) {
|
||||
// Never shown, and (being transient) never filed in history
|
||||
// either — nothing will otherwise dismiss() it, so schedule
|
||||
@@ -240,6 +242,39 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// ── The chime ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// A notification you can hear. GTK applications get this from libcanberra
|
||||
// for free; Panama's own popups had no sound at all, so a notification that
|
||||
// arrived while you were looking elsewhere simply did not happen. Same
|
||||
// theme, same desktop preference: one switch covers the whole session
|
||||
// rather than leaving Panama as the one thing that stays quiet -- or the
|
||||
// one thing that will not shut up.
|
||||
//
|
||||
// Throttled to one bell a second. A burst -- a chat catching up after a
|
||||
// suspend, ten build jobs finishing together -- would otherwise stack a
|
||||
// dozen overlapping bells, which is a noise rather than a notification.
|
||||
property real lastBellAt: 0
|
||||
|
||||
function playBell(notification: var): void {
|
||||
if (!SoundFeedback.eventSounds)
|
||||
return;
|
||||
|
||||
// Low urgency is the "you did not need to know this" tier -- battery
|
||||
// reaching full, a sync completing. It stays silent by design.
|
||||
if (notification.urgency === NotificationUrgency.Low)
|
||||
return;
|
||||
|
||||
const now = Date.now();
|
||||
if (bell.running || now - root.lastBellAt < 1000)
|
||||
return;
|
||||
root.lastBellAt = now;
|
||||
bell.command = SoundFeedback.bellCommand;
|
||||
bell.running = true;
|
||||
}
|
||||
|
||||
Process { id: bell }
|
||||
|
||||
// Runs a DND-hidden transient notification through the same lifetime it
|
||||
// would have gotten as a visible popup (Toast.qml's countdown), just
|
||||
// without ever showing it, so it still gets released instead of staying
|
||||
|
||||
@@ -59,7 +59,8 @@ Singleton {
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence",
|
||||
"gaming": "gaming",
|
||||
"search": "applications"
|
||||
"search": "applications",
|
||||
"sound": "sound"
|
||||
})
|
||||
|
||||
// Settings that are real but have no schema entry, because the system owns
|
||||
@@ -126,6 +127,13 @@ Singleton {
|
||||
{ label: "Input volume", detail: "Choose the microphone and its level", page: "sound" },
|
||||
{ label: "Per-application volume", detail: "Set the level of each application separately", page: "sound" },
|
||||
{ label: "Event sounds", detail: "Play alerts and interface event sounds", page: "sound" },
|
||||
{ label: "Balance", detail: "Shift the output between the left and right speaker", page: "sound" },
|
||||
{ label: "Speaker test", detail: "Play a tone through each channel to find which speaker is which", page: "sound" },
|
||||
{ label: "Microphone test", detail: "Record a few seconds and play it straight back", page: "sound" },
|
||||
{ label: "Alert sound", detail: "The sound theme alerts and Panama's notification chime use", page: "sound" },
|
||||
{ label: "Applications using the microphone", detail: "What is listening right now, and muting it", page: "sound" },
|
||||
{ label: "Move an application's audio", detail: "Send one application to a different output device", page: "sound" },
|
||||
{ label: "Device profiles", detail: "Switch a sound card between stereo, surround, and headset modes", page: "sound" },
|
||||
{ label: "Saved passwords", detail: "The login keyring and what is stored in it", page: "privacy" },
|
||||
{ label: "Camera and microphone", detail: "Which applications may use them", page: "privacy" },
|
||||
{ label: "Screen sharing", detail: "Which applications may capture the screen", page: "privacy" },
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
pragma Singleton
|
||||
|
||||
// Native device profiles -- the sound card's own idea of what it can be.
|
||||
//
|
||||
// A card is one piece of hardware; a profile is one wiring of it. The rear
|
||||
// Realtek can be analog stereo out, or S/PDIF out, or duplex with the line-in
|
||||
// live, but only one at a time, and switching costs the sinks and sources the
|
||||
// old profile provided. That choice is not a PipeWire node property, so
|
||||
// Quickshell's PipeWire bindings cannot see it at all -- which is why this file
|
||||
// shells out where AudioDevices must not.
|
||||
//
|
||||
// `pactl -f json list cards` is the read side (verified against pactl 17 /
|
||||
// pipewire-pulse, which prints profiles and ports as objects keyed by name, not
|
||||
// as arrays). `pactl set-card-profile` is the write side. Both talk to
|
||||
// pipewire-pulse, which owns card state -- so nothing here races the PipeWire
|
||||
// node objects AudioDevices holds.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ name, description, activeProfile,
|
||||
// profiles: [{ name, description, available }], portHint }]
|
||||
property var cards: []
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: lister.running || writer.running
|
||||
|
||||
// True once a listing has completed, however it went, so the UI can tell
|
||||
// "not looked yet" apart from "no cards on this machine".
|
||||
property bool loaded: false
|
||||
|
||||
// A test seam, matching scripts/panama-brightness: point this at a file
|
||||
// holding canned `pactl -f json list cards` output and the service parses
|
||||
// that instead of the live daemon.
|
||||
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_CARDS_FIXTURE") || ""
|
||||
|
||||
function refresh(): void {
|
||||
if (lister.running)
|
||||
return;
|
||||
lister.command = root.fixturePath !== ""
|
||||
? ["cat", root.fixturePath]
|
||||
: ["pactl", "-f", "json", "list", "cards"];
|
||||
lister.running = true;
|
||||
}
|
||||
|
||||
function cardFor(name: string): var {
|
||||
return root.cards.find(card => card.name === name) ?? null;
|
||||
}
|
||||
|
||||
// Switching profile is immediate and lossy: the sinks the old profile
|
||||
// provided disappear. The list is re-read afterwards rather than assumed,
|
||||
// because a card may refuse a profile its ports cannot currently support.
|
||||
function setProfile(cardName: string, profileName: string): void {
|
||||
const card = String(cardName ?? "").trim();
|
||||
const profile = String(profileName ?? "").trim();
|
||||
if (card === "" || profile === "" || writer.running)
|
||||
return;
|
||||
writer.command = ["pactl", "set-card-profile", card, profile];
|
||||
writer.running = true;
|
||||
}
|
||||
|
||||
// The most useful single line about where sound physically comes out.
|
||||
//
|
||||
// Port availability is the only part of a card's state that answers "is
|
||||
// anything plugged in", and it is per-port, so the highest-priority
|
||||
// available port is the one worth naming. A card whose ports all report
|
||||
// "availability unknown" -- USB devices, mostly -- gets no line rather than
|
||||
// a guess, because "unknown" is not "disconnected".
|
||||
function portHintFor(ports: var): string {
|
||||
let best = null;
|
||||
let anyKnown = false;
|
||||
for (const key in ports ?? {}) {
|
||||
const port = ports[key];
|
||||
const availability = String(port?.availability ?? "");
|
||||
if (availability === "available" || availability === "not available")
|
||||
anyKnown = true;
|
||||
if (availability !== "available")
|
||||
continue;
|
||||
if (!best || Number(port.priority ?? 0) > Number(best.priority ?? 0))
|
||||
best = port;
|
||||
}
|
||||
if (best)
|
||||
return `${String(best.description ?? "Port").trim()} connected`;
|
||||
return anyKnown ? "No port connected" : "";
|
||||
}
|
||||
|
||||
function parse(text: string): void {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (error) {
|
||||
root.cards = [];
|
||||
root.lastError = "Device profiles could not be read.";
|
||||
root.loaded = true;
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
root.cards = [];
|
||||
root.lastError = "Device profiles could not be read.";
|
||||
root.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
root.cards = parsed.map(card => {
|
||||
const properties = card.properties ?? {};
|
||||
const profiles = [];
|
||||
for (const key in card.profiles ?? {}) {
|
||||
const profile = card.profiles[key];
|
||||
profiles.push({
|
||||
name: key,
|
||||
description: String(profile?.description ?? key),
|
||||
available: profile?.available !== false,
|
||||
priority: Number(profile?.priority ?? 0)
|
||||
});
|
||||
}
|
||||
// pactl prints profiles in an object, so their order is whatever
|
||||
// the daemon happened to build. Priority is the order PulseAudio
|
||||
// and GNOME both present them in, and it puts "Off" last for free.
|
||||
profiles.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
return {
|
||||
name: String(card.name ?? ""),
|
||||
description: String(properties["device.description"]
|
||||
?? properties["device.nick"]
|
||||
?? card.name
|
||||
?? "Sound card"),
|
||||
activeProfile: String(card.active_profile ?? ""),
|
||||
profiles: profiles,
|
||||
portHint: root.portHintFor(card.ports)
|
||||
};
|
||||
}).filter(card => card.name !== "");
|
||||
|
||||
root.lastError = "";
|
||||
root.loaded = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: lister
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parse(this.text)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.cards = [];
|
||||
root.lastError = "Device profiles could not be read.";
|
||||
root.loaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: writer
|
||||
onExited: (code, status) => {
|
||||
root.lastError = code === 0 ? "" : "That device profile could not be applied.";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
pragma Singleton
|
||||
|
||||
// What you *asked* for, as opposed to what you got.
|
||||
//
|
||||
// PipeWire keeps two different answers to "which is the default output". The
|
||||
// effective one -- the sink audio is actually reaching right now -- is what
|
||||
// Quickshell's `Pipewire.defaultAudioSink` exposes. The configured one is the
|
||||
// device you last chose, remembered by name, and it survives that device being
|
||||
// switched off, unpaired, or carried into another room.
|
||||
//
|
||||
// The two disagree constantly, and the disagreement is the whole story a sound
|
||||
// page should be telling. Choose the Bluetooth headphones, walk away, and the
|
||||
// configured default stays those headphones while the effective default falls
|
||||
// back to the speakers. A list that only renders present devices shows the
|
||||
// speakers selected and no trace of the headphones, which reads as "Panama
|
||||
// forgot" rather than "they are out of range".
|
||||
//
|
||||
// So this service reads the configured names and says, plainly, when one of
|
||||
// them maps to nothing that is currently here. The UI renders that as a ghost
|
||||
// row: visible, dimmed, not selectable, labelled "Returns when connected".
|
||||
//
|
||||
// Read through pw-metadata rather than wpctl. `wpctl status` prints the
|
||||
// *effective* defaults only -- the configured name appears nowhere in its
|
||||
// output -- so it cannot answer the question this file exists to answer.
|
||||
// `pw-metadata -n default 0` prints both, exits immediately, and mutates
|
||||
// nothing.
|
||||
//
|
||||
// Quickshell's `Pipewire.preferredDefaultAudioSink` is the same configured
|
||||
// value, but typed as a node pointer, so it reads null in exactly the case that
|
||||
// matters: the configured device is not here, and there is no node to point at.
|
||||
// A name survives where a pointer cannot, which is why the string is read
|
||||
// directly.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// The names PipeWire remembers as chosen. Empty until the first read
|
||||
// lands, and empty on a session that has never had a default set by hand.
|
||||
property string configuredSinkName: ""
|
||||
property string configuredSourceName: ""
|
||||
|
||||
// The names audio is actually reaching. Kept alongside the configured pair
|
||||
// purely so the two can be compared without a second source of truth.
|
||||
property string effectiveSinkName: ""
|
||||
property string effectiveSourceName: ""
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: reader.running
|
||||
|
||||
// True once a read has completed, however it went -- so the UI can tell
|
||||
// "not looked yet" apart from "looked, and nothing is configured".
|
||||
property bool loaded: false
|
||||
|
||||
// A test seam, matching scripts/panama-brightness: point this at a file
|
||||
// holding canned pw-metadata output and the service parses that instead of
|
||||
// talking to the live graph.
|
||||
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_DEFAULTS_FIXTURE") || ""
|
||||
|
||||
readonly property var presentNames: {
|
||||
const names = {};
|
||||
for (const node of Pipewire.nodes.values) {
|
||||
if (node && !node.isStream && node.name)
|
||||
names[String(node.name)] = true;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// The configured device that is not here, or null. This is exactly what the
|
||||
// ghost row renders; `null` means every configured default is present and
|
||||
// the list has nothing extra to say.
|
||||
readonly property var absentSink: root.configuredSinkName !== ""
|
||||
&& root.presentNames[root.configuredSinkName] !== true
|
||||
? ({ name: root.configuredSinkName, label: root.label(root.configuredSinkName) })
|
||||
: null
|
||||
|
||||
readonly property var absentSource: root.configuredSourceName !== ""
|
||||
&& root.presentNames[root.configuredSourceName] !== true
|
||||
? ({ name: root.configuredSourceName, label: root.label(root.configuredSourceName) })
|
||||
: null
|
||||
|
||||
function absent(output: bool): var {
|
||||
return output ? root.absentSink : root.absentSource;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (reader.running)
|
||||
return;
|
||||
reader.command = root.fixturePath !== ""
|
||||
? ["cat", root.fixturePath]
|
||||
: ["pw-metadata", "-n", "default", "0"];
|
||||
reader.running = true;
|
||||
}
|
||||
|
||||
// A node name turned into something a person can read.
|
||||
//
|
||||
// An absent device has no description to borrow -- descriptions live on the
|
||||
// node, and the node is gone -- so the stored name is all there is. These
|
||||
// names are structured, though, and the structure carries the useful part:
|
||||
// the Bluetooth address, the AirPlay speaker's hostname, the USB device's
|
||||
// product string. Anything this does not recognise is tidied rather than
|
||||
// guessed at, because a wrong name is worse than an ugly one.
|
||||
function label(name: string): string {
|
||||
const raw = String(name ?? "").trim();
|
||||
if (raw === "")
|
||||
return "";
|
||||
|
||||
// bluez_output.74_15_F5_13_A4_28.1 -- nothing but the address.
|
||||
const bluetooth = raw.match(/^bluez_[a-z]+\.([0-9A-Fa-f]{2}(?:_[0-9A-Fa-f]{2}){5})/);
|
||||
if (bluetooth)
|
||||
return `Bluetooth device (${bluetooth[1].replace(/_/g, ":").toUpperCase()})`;
|
||||
|
||||
// raop_sink.Living-Room.local.192.168.1.162.7000 -- the mDNS hostname.
|
||||
const airplay = raw.match(/^raop_[a-z]+\.(.+?)\.local\b/);
|
||||
if (airplay)
|
||||
return airplay[1].replace(/[-_]+/g, " ").trim();
|
||||
|
||||
// alsa_output.usb-Generic_USB_Audio-00.analog-stereo -- the udev bus id
|
||||
// carries the product string, with a two-digit interface number glued
|
||||
// on the end that means nothing to anyone.
|
||||
const usb = raw.match(/^alsa_[a-z]+\.usb-(.+?)(?:-[0-9]{2})?\.[^.]*$/);
|
||||
if (usb)
|
||||
return usb[1].replace(/_+/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const tidied = raw.replace(/^[a-z0-9]+_[a-z]+\./, "")
|
||||
.replace(/[._]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return tidied || raw;
|
||||
}
|
||||
|
||||
// pw-metadata prints one line per key:
|
||||
// update: id:0 key:'default.audio.sink' value:'{"name":"..."}' type:'...'
|
||||
// The value is JSON, and carries `name` -- and, on some setups, a
|
||||
// `description` PipeWire stored alongside it, which is a better label than
|
||||
// anything derivable from the name.
|
||||
function parse(text: string): void {
|
||||
const found = {};
|
||||
for (const line of String(text ?? "").split("\n")) {
|
||||
const matched = line.match(/key:'([^']+)'\s+value:'(.*)'\s+type:'/);
|
||||
if (!matched)
|
||||
continue;
|
||||
try {
|
||||
const value = JSON.parse(matched[2]);
|
||||
const name = String(value?.name ?? "").trim();
|
||||
if (name !== "")
|
||||
found[matched[1]] = name;
|
||||
} catch (error) {
|
||||
// A key whose value is not JSON is not our key. Skip it rather
|
||||
// than failing the whole read.
|
||||
}
|
||||
}
|
||||
|
||||
root.configuredSinkName = found["default.configured.audio.sink"] ?? "";
|
||||
root.configuredSourceName = found["default.configured.audio.source"] ?? "";
|
||||
root.effectiveSinkName = found["default.audio.sink"] ?? "";
|
||||
root.effectiveSourceName = found["default.audio.source"] ?? "";
|
||||
root.loaded = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: reader
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parse(this.text)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
root.lastError = code === 0
|
||||
? ""
|
||||
: "PipeWire's remembered default devices could not be read.";
|
||||
if (code !== 0)
|
||||
root.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The effective default moving is the signal that the configured one may
|
||||
// now be unreachable (or reachable again). Re-reading on that change is
|
||||
// what keeps the ghost row honest without polling.
|
||||
Connections {
|
||||
target: Pipewire
|
||||
function onDefaultAudioSinkChanged(): void { root.refresh(); }
|
||||
function onDefaultAudioSourceChanged(): void { root.refresh(); }
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
pragma Singleton
|
||||
|
||||
// GNOME and GTK applications already honor these desktop sound preferences.
|
||||
// Panama controls the same durable keys so moving between sessions does not
|
||||
// create two competing notions of whether event feedback is enabled.
|
||||
// GNOME and GTK applications already honor these desktop sound preferences --
|
||||
// whether event feedback plays, whether typing clicks, and which sound theme
|
||||
// those samples come from. Panama controls the same durable keys so moving
|
||||
// between sessions does not create two competing notions of any of it, and
|
||||
// plays its own notification chime out of the same theme (see Notifs.qml).
|
||||
//
|
||||
// There is deliberately no alert *volume* here: the desktop sound theme has no
|
||||
// volume channel of its own, and a slider that changed nothing would be worse
|
||||
// than no slider at all.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
@@ -13,9 +19,58 @@ Singleton {
|
||||
|
||||
property bool eventSounds: true
|
||||
property bool inputFeedback: false
|
||||
|
||||
// The XDG sound theme, by directory name. "freedesktop" is the one every
|
||||
// distribution ships and the fallback everything else is layered over.
|
||||
property string soundTheme: "freedesktop"
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: eventRead.running || inputRead.running
|
||||
|| eventWrite.running || inputWrite.running
|
||||
|| themeRead.running || eventWrite.running || inputWrite.running
|
||||
|| themeWrite.running
|
||||
|
||||
// [{ directory, name }] -- the directory is what gsettings stores, the name
|
||||
// is what the theme calls itself in its index.theme.
|
||||
property var themes: [{ directory: "freedesktop", name: "Default" }]
|
||||
|
||||
// ── The alert sound ─────────────────────────────────────────────────────
|
||||
//
|
||||
// A theme is a directory of samples with a fallback chain, not a single
|
||||
// file, and a theme that overrides only a handful of sounds is normal. So
|
||||
// the bell is resolved as a list of candidates in preference order --
|
||||
// user-installed theme, system theme, freedesktop -- and the first one that
|
||||
// exists is played. Resolving it in the shell rather than here keeps this
|
||||
// free of file probing on a hot path.
|
||||
readonly property string homeDir: Quickshell.env("HOME") || ""
|
||||
|
||||
readonly property var bellCandidates: [
|
||||
root.homeDir !== ""
|
||||
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/bell.oga` : "",
|
||||
`/usr/share/sounds/${root.soundTheme}/stereo/bell.oga`,
|
||||
"/usr/share/sounds/freedesktop/stereo/bell.oga"
|
||||
].filter((path, index, all) => path !== "" && all.indexOf(path) === index)
|
||||
|
||||
// The argv that plays the current theme's bell once, or nothing at all if
|
||||
// no candidate exists. Shared with Notifs.qml, which plays the same bell
|
||||
// for Panama's own notification popups.
|
||||
readonly property var bellCommand: ["sh", "-c",
|
||||
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
|
||||
"qs-sound-feedback"].concat(root.bellCandidates)
|
||||
|
||||
function previewAlert(): void {
|
||||
if (preview.running)
|
||||
return;
|
||||
preview.command = root.bellCommand;
|
||||
preview.running = true;
|
||||
}
|
||||
|
||||
function setSoundTheme(name: string): void {
|
||||
const theme = String(name ?? "").trim();
|
||||
if (theme === "")
|
||||
return;
|
||||
root.soundTheme = theme;
|
||||
root._writeSoundTheme();
|
||||
}
|
||||
|
||||
function parsedBoolean(text: string, fallback: bool): bool {
|
||||
const value = text.trim();
|
||||
@@ -31,6 +86,10 @@ Singleton {
|
||||
eventRead.running = true;
|
||||
if (!inputRead.running)
|
||||
inputRead.running = true;
|
||||
if (!themeRead.running)
|
||||
themeRead.running = true;
|
||||
if (!themeScan.running)
|
||||
themeScan.running = true;
|
||||
}
|
||||
|
||||
function setEventSounds(enabled: bool): void {
|
||||
@@ -64,6 +123,20 @@ Singleton {
|
||||
inputWrite.running = true;
|
||||
}
|
||||
|
||||
function _writeSoundTheme(): void {
|
||||
if (themeWrite.running)
|
||||
return;
|
||||
themeWrite.writtenValue = root.soundTheme;
|
||||
themeWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "theme-name", root.soundTheme];
|
||||
themeWrite.running = true;
|
||||
}
|
||||
|
||||
// gsettings prints strings quoted: 'freedesktop'.
|
||||
function parsedString(text: string, fallback: string): string {
|
||||
const value = text.trim().replace(/^'(.*)'$/, "$1");
|
||||
return value === "" ? fallback : value;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: eventRead
|
||||
command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"]
|
||||
@@ -118,5 +191,70 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: themeRead
|
||||
command: ["gsettings", "get", "org.gnome.desktop.sound", "theme-name"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.soundTheme = root.parsedString(this.text, root.soundTheme)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0)
|
||||
root.lastError = "The alert sound theme could not be read.";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: themeWrite
|
||||
property string writtenValue: "freedesktop"
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.lastError = "The alert sound theme could not be changed.";
|
||||
root.refresh();
|
||||
} else {
|
||||
root.lastError = "";
|
||||
}
|
||||
if (root.soundTheme !== themeWrite.writtenValue)
|
||||
root._writeSoundTheme();
|
||||
}
|
||||
}
|
||||
|
||||
// Installed themes, from the index.theme every XDG sound theme must carry.
|
||||
// Emitted as directory<TAB>name so the pair cannot drift apart; a theme
|
||||
// whose index.theme has no Name= falls back to its directory rather than
|
||||
// being dropped, because it is still selectable and still works.
|
||||
Process {
|
||||
id: themeScan
|
||||
command: ["sh", "-c", `
|
||||
for theme in "$HOME"/.local/share/sounds/*/index.theme /usr/share/sounds/*/index.theme; do
|
||||
[ -f "$theme" ] || continue
|
||||
directory=$(basename "$(dirname "$theme")")
|
||||
name=$(sed -n 's/^Name=//p' "$theme" | head -n1)
|
||||
[ -n "$name" ] || name=$directory
|
||||
printf '%s\\t%s\\n' "$directory" "$name"
|
||||
done
|
||||
`]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const found = [];
|
||||
const seen = {};
|
||||
for (const line of String(this.text).split("\n")) {
|
||||
const parts = line.split("\t");
|
||||
const directory = String(parts[0] ?? "").trim();
|
||||
if (directory === "" || seen[directory])
|
||||
continue;
|
||||
seen[directory] = true;
|
||||
found.push({ directory, name: String(parts[1] ?? "").trim() || directory });
|
||||
}
|
||||
// Never hand the UI an empty picker: freedesktop is the theme
|
||||
// every fallback chain ends at, present or not.
|
||||
if (!seen["freedesktop"])
|
||||
found.push({ directory: "freedesktop", name: "Default" });
|
||||
root.themes = found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process { id: preview }
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
pragma Singleton
|
||||
|
||||
// Sending one application's audio to a different output.
|
||||
//
|
||||
// PipeWire routes a playback stream either by following the system default or
|
||||
// by a per-stream target the session manager remembers. Quickshell's PipeWire
|
||||
// bindings expose the graph but not that routing decision, so moving a stream
|
||||
// means shelling out -- which is why this lives beside AudioDevices rather than
|
||||
// inside it, on the same reasoning as SoundTest.
|
||||
//
|
||||
// ── Reading: native ─────────────────────────────────────────────────────────
|
||||
// `currentSinkFor` walks `Pipewire.linkGroups`, which already says which sink a
|
||||
// stream's links land on. Nothing is shelled out to read.
|
||||
//
|
||||
// It reports "" -- follows the default -- whenever the stream is linked to the
|
||||
// sink that is currently the default. That is a deliberate simplification: an
|
||||
// application explicitly pinned to the device that also happens to be the
|
||||
// default is indistinguishable from one that is merely following it, and the
|
||||
// difference is inaudible for as long as it lasts. The moment the default moves
|
||||
// away, the pinned stream stays put and starts reporting its real sink, which
|
||||
// is the case the picker actually needs to get right. The alternative --
|
||||
// polling `pw-metadata` for per-node target keys -- buys a distinction nobody
|
||||
// can hear, at the cost of a timer.
|
||||
//
|
||||
// ── Writing: two different mechanisms, deliberately ─────────────────────────
|
||||
// `moveApplication` uses `pactl move-sink-input <serial> <sink>`. The serial is
|
||||
// `object.serial`, which is exactly what pipewire-pulse presents as a PulseAudio
|
||||
// index (verified: `pactl -f json list` prints `index` equal to the object's
|
||||
// `object.serial`), so no id translation is needed.
|
||||
//
|
||||
// `routeToDefault` cannot use the same call, because moving a stream *to* the
|
||||
// default sink pins it there -- it would stop following, and quietly stay behind
|
||||
// the next time the default changed. Pinning is stored as a `target.object` key
|
||||
// on the stream's node id in PipeWire's "default" metadata, so releasing it is
|
||||
// deleting that key: `pw-metadata -n default -d <node-id> target.object`. The
|
||||
// session manager sees the metadata change and re-links the stream to whatever
|
||||
// the default is now. The legacy `target.node` key is deleted alongside it,
|
||||
// because streams pinned by older tooling carry that one instead.
|
||||
//
|
||||
// Note the id asymmetry: pactl speaks object serials, pw-metadata speaks node
|
||||
// ids. They are different numbers for the same stream.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: mover.running || root.queue.length > 0
|
||||
|
||||
// Pending commands, run one at a time. An application on three streams is
|
||||
// three calls, and letting them overlap means three processes racing for
|
||||
// the same metadata.
|
||||
property var queue: []
|
||||
|
||||
function streamSerial(node: var): string {
|
||||
const serial = String(node?.properties?.["object.serial"] ?? "").trim();
|
||||
return serial !== "" ? serial : String(node?.id ?? "");
|
||||
}
|
||||
|
||||
// Name of the sink this application's audio is reaching, or "" when it is
|
||||
// simply following the system default. See the note above on why those two
|
||||
// answers merge while the default *is* that sink.
|
||||
function currentSinkFor(group: var): string {
|
||||
const nodes = group?.nodes ?? [];
|
||||
if (nodes.length === 0)
|
||||
return "";
|
||||
|
||||
const stream = nodes[0];
|
||||
const defaultSink = Pipewire.defaultAudioSink;
|
||||
for (const linkGroup of Pipewire.linkGroups.values) {
|
||||
if (linkGroup?.source !== stream || !linkGroup?.target)
|
||||
continue;
|
||||
if (defaultSink && linkGroup.target === defaultSink)
|
||||
return "";
|
||||
return String(linkGroup.target.name ?? "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Pin every stream of this application to one sink.
|
||||
function moveApplication(group: var, sinkName: string): void {
|
||||
const sink = String(sinkName ?? "").trim();
|
||||
const nodes = group?.nodes ?? [];
|
||||
if (sink === "" || nodes.length === 0)
|
||||
return;
|
||||
|
||||
const commands = [];
|
||||
for (const node of nodes) {
|
||||
const serial = root.streamSerial(node);
|
||||
if (serial !== "")
|
||||
commands.push(["pactl", "move-sink-input", serial, sink]);
|
||||
}
|
||||
root.enqueue(commands, "That application's audio could not be moved.");
|
||||
}
|
||||
|
||||
// Release the pin, so the application follows the system default again.
|
||||
//
|
||||
// Mechanism: delete the stream's routing key from PipeWire's "default"
|
||||
// metadata -- `pw-metadata -n default -d <node-id> target.object`, plus the
|
||||
// legacy `target.node` for streams older tooling pinned. The session
|
||||
// manager re-links on the metadata change.
|
||||
//
|
||||
// Deliberately NOT `pactl move-sink-input <serial> <default sink>`: that
|
||||
// writes the pin rather than clearing it, so the application would sit on
|
||||
// today's default forever and quietly stop following tomorrow's.
|
||||
//
|
||||
// Note the id asymmetry -- pw-metadata takes the node id, pactl takes the
|
||||
// object serial, and they are different numbers for the same stream.
|
||||
function routeToDefault(group: var): void {
|
||||
const nodes = group?.nodes ?? [];
|
||||
if (nodes.length === 0)
|
||||
return;
|
||||
|
||||
const commands = [];
|
||||
for (const node of nodes) {
|
||||
const id = String(node?.id ?? "").trim();
|
||||
if (id === "")
|
||||
continue;
|
||||
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.object"]);
|
||||
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.node"]);
|
||||
}
|
||||
root.enqueue(commands, "That application could not be returned to the default output.");
|
||||
}
|
||||
|
||||
function enqueue(commands: var, failureMessage: string): void {
|
||||
if (commands.length === 0)
|
||||
return;
|
||||
root.lastError = "";
|
||||
root.queue = root.queue.concat(commands.map(command => ({
|
||||
command: command,
|
||||
failureMessage: failureMessage
|
||||
})));
|
||||
root.pump();
|
||||
}
|
||||
|
||||
property string pendingFailure: ""
|
||||
|
||||
function pump(): void {
|
||||
if (mover.running || root.queue.length === 0)
|
||||
return;
|
||||
const next = root.queue[0];
|
||||
root.queue = root.queue.slice(1);
|
||||
root.pendingFailure = next.failureMessage;
|
||||
mover.command = next.command;
|
||||
mover.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mover
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0)
|
||||
root.lastError = root.pendingFailure;
|
||||
root.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,205 @@
|
||||
pragma Singleton
|
||||
|
||||
// Playing a short sound out of one chosen output.
|
||||
// Playing a short sound out of one chosen output, and hearing your own
|
||||
// microphone back.
|
||||
//
|
||||
// Deliberately not part of AudioDevices, which owns device state through the
|
||||
// Quickshell PipeWire bindings and must not shell out -- doing so there would
|
||||
// race the service that owns those same objects. This spawns a short-lived
|
||||
// playback client instead: it creates its own stream and mutates no device, so
|
||||
// there is nothing for it to race.
|
||||
// race the service that owns those same objects. This spawns short-lived
|
||||
// playback and capture clients instead: they create their own streams and
|
||||
// mutate no device, so there is nothing for them to race.
|
||||
//
|
||||
// It exists because nine outputs named after their chipsets cannot be told
|
||||
// apart by reading. The only way to know which is which is to hear one.
|
||||
// apart by reading. The only way to know which is which is to hear one -- and
|
||||
// on a device with more than two channels, the only way to know the rear pair
|
||||
// is wired the right way round is to hear each channel on its own.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string sampleDir: "/usr/share/sounds/freedesktop/stereo"
|
||||
|
||||
// A short, unmistakable, front-and-centre sample that ships with the
|
||||
// freedesktop sound theme, so nothing has to be bundled.
|
||||
readonly property string sample:
|
||||
"/usr/share/sounds/freedesktop/stereo/audio-channel-front-center.oga"
|
||||
readonly property string sample: `${root.sampleDir}/audio-channel-front-center.oga`
|
||||
|
||||
readonly property bool playing: player.running
|
||||
|
||||
// The channel currently being played, "" when nothing is. Drives the lit
|
||||
// chip in the channel strip.
|
||||
property string playingChannel: ""
|
||||
|
||||
// ── Channels ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The freedesktop theme ships one spoken sample per channel, and the set it
|
||||
// ships is the set that can be tested. A channel with no sample on disk --
|
||||
// the LFE, chiefly, which has no name to speak -- is left out of the strip
|
||||
// rather than given a chip that would do nothing when pressed.
|
||||
readonly property var channelSamples: {
|
||||
const samples = {};
|
||||
samples[PwAudioChannel.Mono] = { name: "Mono", label: "Mono", file: "audio-channel-front-center" };
|
||||
samples[PwAudioChannel.FrontLeft] = { name: "FrontLeft", label: "Front Left", file: "audio-channel-front-left" };
|
||||
samples[PwAudioChannel.FrontRight] = { name: "FrontRight", label: "Front Right", file: "audio-channel-front-right" };
|
||||
samples[PwAudioChannel.FrontCenter] = { name: "FrontCenter", label: "Center", file: "audio-channel-front-center" };
|
||||
samples[PwAudioChannel.SideLeft] = { name: "SideLeft", label: "Side Left", file: "audio-channel-side-left" };
|
||||
samples[PwAudioChannel.SideRight] = { name: "SideRight", label: "Side Right", file: "audio-channel-side-right" };
|
||||
samples[PwAudioChannel.RearLeft] = { name: "RearLeft", label: "Rear Left", file: "audio-channel-rear-left" };
|
||||
samples[PwAudioChannel.RearRight] = { name: "RearRight", label: "Rear Right", file: "audio-channel-rear-right" };
|
||||
samples[PwAudioChannel.RearCenter] = { name: "RearCenter", label: "Rear Center", file: "audio-channel-rear-center" };
|
||||
return samples;
|
||||
}
|
||||
|
||||
// Ordered [{ name, label }] for one device, in the order PipeWire reports
|
||||
// its channels -- which is the order they are physically wired, so the
|
||||
// strip reads left to right the way the speakers stand.
|
||||
function channelsFor(node: var): var {
|
||||
const channels = node?.audio?.channels ?? [];
|
||||
const out = [];
|
||||
for (const channel of channels) {
|
||||
const known = root.channelSamples[channel];
|
||||
if (known && !out.some(entry => entry.name === known.name))
|
||||
out.push({ name: known.name, label: known.label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sampleFor(channelName: string): string {
|
||||
for (const key in root.channelSamples) {
|
||||
const known = root.channelSamples[key];
|
||||
if (known.name === channelName)
|
||||
return `${root.sampleDir}/${known.file}.oga`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Targeted by node name taken straight from the live node. pw-play falls
|
||||
// back to the default output for a target it cannot find, so a stale name
|
||||
// would play out of the wrong device and look like the test had worked.
|
||||
function play(node: var): void {
|
||||
root.playSample(node, root.sample, "");
|
||||
}
|
||||
|
||||
function playChannel(node: var, channelName: string): void {
|
||||
const file = root.sampleFor(String(channelName ?? ""));
|
||||
if (file === "")
|
||||
return;
|
||||
root.playSample(node, file, String(channelName));
|
||||
}
|
||||
|
||||
function playSample(node: var, file: string, channelName: string): void {
|
||||
const target = String(node?.name ?? "");
|
||||
if (target === "" || player.running)
|
||||
return;
|
||||
player.command = ["pw-play", "--target", target, root.sample];
|
||||
root.playingChannel = channelName;
|
||||
player.command = ["pw-play", "--target", target, file];
|
||||
player.running = true;
|
||||
}
|
||||
|
||||
Process { id: player }
|
||||
Process {
|
||||
id: player
|
||||
onExited: (code, status) => root.playingChannel = ""
|
||||
}
|
||||
|
||||
// ── Microphone test ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Record a few seconds, then play them straight back. A level meter proves
|
||||
// the microphone is producing samples; only hearing yourself proves it is
|
||||
// producing *you*, at a usable level, through the output you are wearing.
|
||||
//
|
||||
// "idle" -> "recording" -> "playing" -> "idle". One file, in the runtime
|
||||
// directory, overwritten every run: recordings of the user are not
|
||||
// something to leave lying around, and the runtime directory is cleared
|
||||
// when the session ends.
|
||||
readonly property string micTestPath:
|
||||
`${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/panama-mic-test.wav`
|
||||
|
||||
property string micTestState: "idle"
|
||||
|
||||
readonly property int micTestSeconds: 3
|
||||
readonly property int micTestRate: 48000
|
||||
|
||||
function startMicTest(sourceNode: var, sinkNode: var): void {
|
||||
const source = String(sourceNode?.name ?? "");
|
||||
if (source === "" || root.micTestState !== "idle")
|
||||
return;
|
||||
|
||||
// pw-play needs a target too, or the playback lands on the default
|
||||
// output rather than the one being looked at.
|
||||
root.micTestSink = String(sinkNode?.name ?? "");
|
||||
|
||||
// `-n` makes pw-record stop itself after exactly this many samples and
|
||||
// close the file cleanly, which is what leaves a playable WAV header
|
||||
// behind. The timer below is the watchdog for a capture device that
|
||||
// never produces a sample at all, where the count would never be
|
||||
// reached and the test would hang in "recording" forever.
|
||||
recorder.command = [
|
||||
"pw-record", "--target", source,
|
||||
"--rate", String(root.micTestRate),
|
||||
"-n", String(root.micTestRate * root.micTestSeconds),
|
||||
root.micTestPath
|
||||
];
|
||||
root.micTestState = "recording";
|
||||
recorder.running = true;
|
||||
micTestWatchdog.restart();
|
||||
}
|
||||
|
||||
function cancelMicTest(): void {
|
||||
micTestWatchdog.stop();
|
||||
// Only arm the cancelled flag if something is actually going to exit
|
||||
// and read it -- otherwise it would survive to poison the next run.
|
||||
root.micTestCancelled = recorder.running || playback.running;
|
||||
root.micTestState = "idle";
|
||||
if (recorder.running)
|
||||
recorder.signal(15);
|
||||
if (playback.running)
|
||||
playback.signal(15);
|
||||
}
|
||||
|
||||
property string micTestSink: ""
|
||||
property bool micTestCancelled: false
|
||||
|
||||
Timer {
|
||||
id: micTestWatchdog
|
||||
// Comfortably past the sample count, so it only ever fires for a
|
||||
// capture that has stalled rather than one that is merely slow.
|
||||
interval: (root.micTestSeconds + 3) * 1000
|
||||
onTriggered: {
|
||||
if (recorder.running)
|
||||
recorder.signal(15);
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: recorder
|
||||
onExited: (code, status) => {
|
||||
micTestWatchdog.stop();
|
||||
if (root.micTestCancelled) {
|
||||
root.micTestCancelled = false;
|
||||
root.micTestState = "idle";
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
root.micTestState = "idle";
|
||||
return;
|
||||
}
|
||||
root.micTestState = "playing";
|
||||
playback.command = root.micTestSink !== ""
|
||||
? ["pw-play", "--target", root.micTestSink, root.micTestPath]
|
||||
: ["pw-play", root.micTestPath];
|
||||
playback.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: playback
|
||||
onExited: (code, status) => {
|
||||
root.micTestCancelled = false;
|
||||
root.micTestState = "idle";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Read-only contract harness for the Sound page. It instantiates every device
|
||||
// row against the real PipeWire graph but exposes no mutating IPC methods.
|
||||
//
|
||||
// The two services that read the machine rather than the graph -- SoundDefaults
|
||||
// and SoundCards -- are fed through their own fixture seams by the contract, so
|
||||
// the ghost row and the profile card can be measured against canned state
|
||||
// instead of against whatever hardware is plugged in today.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
@@ -34,6 +39,20 @@ ShellRoot {
|
||||
height: 900
|
||||
}
|
||||
|
||||
// A device list of its own, so the ghost row can be read without reaching
|
||||
// into the page's internals.
|
||||
SoundDeviceList {
|
||||
id: outputList
|
||||
width: 620
|
||||
visible: false
|
||||
output: true
|
||||
}
|
||||
|
||||
SoundCaptureRow {
|
||||
id: captureProbe
|
||||
width: 620
|
||||
}
|
||||
|
||||
ApplicationMixer {
|
||||
id: populatedMixer
|
||||
width: 620
|
||||
@@ -78,7 +97,15 @@ ShellRoot {
|
||||
emptyRows: emptyMixer.rowCount,
|
||||
emptyStatus: emptyMixer.statusText,
|
||||
unavailableRows: unavailableMixer.rowCount,
|
||||
unavailableStatus: unavailableMixer.statusText
|
||||
unavailableStatus: unavailableMixer.statusText,
|
||||
captureApplications: AudioDevices.captureApplications.length,
|
||||
captureTypesValid: AudioDevices.captureApplications.every(application =>
|
||||
application.nodes.every(node =>
|
||||
(node.type & PwNodeType.AudioInStream)
|
||||
=== PwNodeType.AudioInStream)),
|
||||
captureRowVisible: captureProbe.visible,
|
||||
ghostVisible: !!outputList.absent,
|
||||
ghostLabel: outputList.absent ? String(outputList.absent.label) : ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Fixture-fed harness for the three sound services that shell out:
|
||||
// SoundCards (pactl list cards / set-card-profile), SoundRouting (pactl
|
||||
// move-sink-input) and SoundDefaults (pw-metadata -n default).
|
||||
//
|
||||
// Nothing here touches the real audio graph. The contracts that drive this file
|
||||
// replace pactl and pw-metadata with recording stubs, so what is under test is
|
||||
// the parsing and the argv -- not PipeWire, which has its own opinions and a
|
||||
// different set on every machine.
|
||||
//
|
||||
// Application groups arrive as plain objects with the same shape AudioStreams.js
|
||||
// produces, because that is what the services are handed at runtime and it is
|
||||
// the part a refactor is most likely to break silently.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
// Two streams of one application, the way PipeWire numbers them: an
|
||||
// `object.serial` that pactl accepts, distinct from the node id.
|
||||
readonly property var twoStreamGroup: ({
|
||||
key: "org.chromium.Chromium",
|
||||
label: "Chromium",
|
||||
icon: "chromium",
|
||||
nodes: [
|
||||
{ id: 61, properties: { "object.serial": "412" } },
|
||||
{ id: 62, properties: { "object.serial": "418" } }
|
||||
]
|
||||
})
|
||||
|
||||
// A stream PipeWire never gave a serial. The node id is the fallback, and
|
||||
// it has to be one: an application that cannot be moved is worse than one
|
||||
// moved by a less stable handle.
|
||||
readonly property var seriallessGroup: ({
|
||||
key: "mpv",
|
||||
label: "mpv",
|
||||
icon: "mpv",
|
||||
nodes: [{ id: 77, properties: {} }]
|
||||
})
|
||||
|
||||
readonly property var emptyGroup: ({
|
||||
key: "gone",
|
||||
label: "Gone",
|
||||
icon: "",
|
||||
nodes: []
|
||||
})
|
||||
|
||||
function groupNamed(name: string): var {
|
||||
if (name === "twoStream")
|
||||
return root.twoStreamGroup;
|
||||
if (name === "serialless")
|
||||
return root.seriallessGroup;
|
||||
return root.emptyGroup;
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "sound-services-test"
|
||||
|
||||
// ── SoundCards ──────────────────────────────────────────────────────
|
||||
function cards(): string {
|
||||
return JSON.stringify({
|
||||
busy: SoundCards.busy,
|
||||
lastError: SoundCards.lastError,
|
||||
cards: SoundCards.cards
|
||||
});
|
||||
}
|
||||
|
||||
function refreshCards(): bool {
|
||||
SoundCards.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setProfile(card: string, profile: string): bool {
|
||||
return SoundCards.setProfile(card, profile) !== false;
|
||||
}
|
||||
|
||||
// ── SoundRouting ────────────────────────────────────────────────────
|
||||
function routing(): string {
|
||||
return JSON.stringify({
|
||||
busy: SoundRouting.busy,
|
||||
lastError: SoundRouting.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function moveApplication(group: string, sink: string): bool {
|
||||
return SoundRouting.moveApplication(root.groupNamed(group), sink) !== false;
|
||||
}
|
||||
|
||||
function routeToDefault(group: string): bool {
|
||||
return SoundRouting.routeToDefault(root.groupNamed(group)) !== false;
|
||||
}
|
||||
|
||||
function currentSinkFor(group: string): string {
|
||||
return String(SoundRouting.currentSinkFor(root.groupNamed(group)) ?? "");
|
||||
}
|
||||
|
||||
// ── SoundDefaults ───────────────────────────────────────────────────
|
||||
function defaults(): string {
|
||||
return JSON.stringify({
|
||||
sink: SoundDefaults.configuredSinkName,
|
||||
source: SoundDefaults.configuredSourceName
|
||||
});
|
||||
}
|
||||
|
||||
function refreshDefaults(): bool {
|
||||
SoundDefaults.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
// What the ghost row would render, and what it would call the device.
|
||||
// The name is all there is to go on: the node is gone, so there is no
|
||||
// description to borrow.
|
||||
function labelFor(name: string): string {
|
||||
return SoundDefaults.label(name);
|
||||
}
|
||||
|
||||
function absent(output: string): string {
|
||||
const record = SoundDefaults.absent(output === "output");
|
||||
return JSON.stringify(record === null ? { present: true } : {
|
||||
present: false,
|
||||
name: record.name,
|
||||
label: record.label
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,6 @@
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Sound in Settings.
|
||||
# @vicinae.keywords ["settings", "output volume", "input volume", "per-application volume", "event sounds"]
|
||||
# @vicinae.keywords ["settings", "over-amplification", "volume-change blip", "output volume", "input volume", "per-application volume", "event sounds", "balance", "speaker test", "microphone test", "alert sound", "applications using the microphone", "move an application's audio"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page sound
|
||||
|
||||
Reference in New Issue
Block a user