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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user