// 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 import qs.config import qs.modules.quicksettings import qs.services import qs.widgets Rectangle { id: root 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 + (root.picking ? sinkMenu.implicitHeight + 6 : 0) radius: Theme.cardRadius color: Theme.alpha(Theme.fg, 0.025) border.width: 1 border.color: Theme.alpha(Theme.fg, 0.07) PwObjectTracker { objects: root.application?.nodes ?? [] } ThemedIcon { id: applicationIcon anchors.left: parent.left anchors.leftMargin: 12 anchors.top: parent.top anchors.topMargin: 11 size: 20 icon: root.application?.icon ?? "audio-x-generic-symbolic" iconFallback: "audio-x-generic-symbolic" 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: sinkButton.left anchors.rightMargin: 12 anchors.verticalCenter: applicationIcon.verticalCenter spacing: 1 Text { width: parent.width text: root.application?.label ?? "Unknown application" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.Medium elide: Text.ElideRight } Text { width: parent.width visible: (root.application?.nodes?.length ?? 0) > 1 text: `${root.application?.nodes?.length ?? 0} audio streams` color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } IconButton { id: muteButton anchors.left: parent.left anchors.leftMargin: 8 anchors.top: parent.top anchors.topMargin: 42 size: 30 iconSize: 17 icon: root.muted || root.volume <= 0.001 ? "audio-volume-muted-symbolic" : "audio-volume-high-symbolic" iconFallback: "audio-volume-high-symbolic" onClicked: AudioDevices.setApplicationMuted(root.application, !root.muted) } ValueSlider { 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 => AudioDevices.setApplicationVolume(root.application, value) } 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 } // 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; } } } } } }