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:
Gabriel Brown
2026-08-24 11:39:16 -04:00
parent 9bc68ba358
commit b58371bb35
38 changed files with 3884 additions and 213 deletions
@@ -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