491 lines
12 KiB
QML
491 lines
12 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
import Quickshell
|
|
import Quickshell.Services.Pipewire
|
|
import Quickshell.Widgets
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import QtQuick.Controls
|
|
import qs.Config
|
|
import qs.Components
|
|
import qs.Daemons
|
|
import qs.Helpers
|
|
|
|
Item {
|
|
id: root
|
|
|
|
implicitWidth: layout.implicitWidth + 10 * 2
|
|
implicitHeight: layout.implicitHeight + 10 * 2
|
|
|
|
required property var wrapper
|
|
|
|
ColumnLayout {
|
|
id: layout
|
|
|
|
anchors.top: parent.top
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
implicitWidth: stack.currentItem ? stack.currentItem.childrenRect.height : 0
|
|
spacing: 12
|
|
|
|
RowLayout {
|
|
id: tabBar
|
|
spacing: 6
|
|
Layout.fillWidth: true
|
|
property int tabHeight: 36
|
|
|
|
CustomClippingRect {
|
|
radius: 6
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: tabBar.tabHeight
|
|
|
|
color: stack.currentIndex === 0 ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer
|
|
|
|
StateLayer {
|
|
|
|
function onClicked(): void {
|
|
stack.currentIndex = 0;
|
|
}
|
|
|
|
CustomText {
|
|
text: qsTr("Volumes")
|
|
anchors.centerIn: parent
|
|
color: stack.currentIndex === 0 ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3primary
|
|
}
|
|
}
|
|
}
|
|
|
|
CustomClippingRect {
|
|
radius: 6
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: tabBar.tabHeight
|
|
|
|
color: stack.currentIndex === 1 ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer
|
|
|
|
StateLayer {
|
|
|
|
function onClicked(): void {
|
|
stack.currentIndex = 1;
|
|
}
|
|
|
|
CustomText {
|
|
text: qsTr("Devices")
|
|
anchors.centerIn: parent
|
|
color: stack.currentIndex === 1 ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3primary
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
StackLayout {
|
|
id: stack
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: currentIndex === 0 ? vol.childrenRect.height : dev.childrenRect.height
|
|
currentIndex: 0
|
|
|
|
VolumesTab { id: vol }
|
|
DevicesTab { id: dev }
|
|
|
|
Behavior on currentIndex {
|
|
SequentialAnimation {
|
|
ParallelAnimation {
|
|
Anim {
|
|
target: stack
|
|
property: "opacity"
|
|
to: 0
|
|
duration: MaterialEasing.expressiveEffectsTime
|
|
}
|
|
|
|
Anim {
|
|
target: stack
|
|
property: "scale"
|
|
to: 0.9
|
|
duration: MaterialEasing.expressiveEffectsTime
|
|
}
|
|
}
|
|
|
|
PropertyAction {}
|
|
|
|
ParallelAnimation {
|
|
Anim {
|
|
target: stack
|
|
property: "opacity"
|
|
to: 1
|
|
duration: MaterialEasing.expressiveEffectsTime
|
|
}
|
|
|
|
Anim {
|
|
target: stack
|
|
property: "scale"
|
|
to: 1
|
|
duration: MaterialEasing.expressiveEffectsTime
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
component VolumesTab: ColumnLayout {
|
|
spacing: 12
|
|
|
|
RowLayout {
|
|
Layout.topMargin: 10
|
|
spacing: 15
|
|
Rectangle {
|
|
Layout.preferredWidth: 40
|
|
Layout.preferredHeight: 40
|
|
Layout.alignment: Qt.AlignVCenter
|
|
color: DynamicColors.palette.m3primary
|
|
radius: 1000
|
|
MaterialIcon {
|
|
anchors.centerIn: parent
|
|
color: DynamicColors.palette.m3onPrimary
|
|
text: "volume_up"
|
|
font.pointSize: 22
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
|
|
CustomText {
|
|
text: "Output Volume"
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
|
|
}
|
|
|
|
CustomText {
|
|
text: qsTr("%1").arg(Audio.muted ? qsTr("Muted") : `${Math.round(Audio.volume * 100)}%`);
|
|
font.bold: true
|
|
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
|
|
}
|
|
}
|
|
|
|
CustomMouseArea {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 10
|
|
Layout.bottomMargin: 5
|
|
|
|
CustomSlider {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
implicitHeight: 10
|
|
value: Audio.volume
|
|
onMoved: Audio.setVolume(value)
|
|
|
|
Behavior on value { Anim {} }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
RowLayout {
|
|
Layout.topMargin: 10
|
|
spacing: 15
|
|
Rectangle {
|
|
Layout.preferredWidth: 40
|
|
Layout.preferredHeight: 40
|
|
Layout.alignment: Qt.AlignVCenter
|
|
color: DynamicColors.palette.m3primary
|
|
radius: 1000
|
|
MaterialIcon {
|
|
anchors.centerIn: parent
|
|
anchors.alignWhenCentered: false
|
|
color: DynamicColors.palette.m3onPrimary
|
|
text: "mic"
|
|
font.pointSize: 22
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
|
|
CustomText {
|
|
text: "Input Volume"
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
|
|
}
|
|
|
|
CustomText {
|
|
text: qsTr("%1").arg(Audio.sourceMuted ? qsTr("Muted") : `${Math.round(Audio.sourceVolume * 100)}%`);
|
|
font.bold: true
|
|
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
|
|
}
|
|
}
|
|
|
|
CustomMouseArea {
|
|
Layout.fillWidth: true
|
|
Layout.bottomMargin: 5
|
|
implicitHeight: 10
|
|
|
|
CustomSlider {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
implicitHeight: 10
|
|
value: Audio.sourceVolume
|
|
onMoved: Audio.setSourceVolume(value)
|
|
|
|
Behavior on value { Anim {} }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.topMargin: 10
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 1
|
|
|
|
color: DynamicColors.tPalette.m3outline
|
|
}
|
|
|
|
Repeater {
|
|
model: Audio.appStreams
|
|
|
|
Item {
|
|
id: appBox
|
|
|
|
Layout.topMargin: 10
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 42
|
|
visible: !isCaptureStream
|
|
required property PwNode modelData
|
|
|
|
function isValidMatch(searchTerm, entry) {
|
|
if (!entry)
|
|
return false;
|
|
var search = searchTerm.toLowerCase();
|
|
var id = (entry.id || "").toLowerCase();
|
|
var name = (entry.name || "").toLowerCase();
|
|
var icon = (entry.icon || "").toLowerCase();
|
|
// Match is valid if search term appears in entry or entry appears in search
|
|
return id.includes(search) || name.includes(search) || icon.includes(search) || search.includes(id.split('.').pop()) || search.includes(name.replace(/\s+/g, ''));
|
|
}
|
|
|
|
readonly property string appName: {
|
|
if (!modelData)
|
|
return "Unknown App";
|
|
|
|
var props = modelData.properties;
|
|
var desc = modelData.description || "";
|
|
var name = modelData.name || "";
|
|
var mediaName = props["media.name"] || "";
|
|
|
|
if ( mediaName !== "playStream" ) {
|
|
return mediaName;
|
|
}
|
|
|
|
if (!props) {
|
|
if (desc)
|
|
return desc;
|
|
if (name) {
|
|
var nameParts = name.split(/[-_]/);
|
|
if (nameParts.length > 0 && nameParts[0])
|
|
return nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1);
|
|
return name;
|
|
}
|
|
return "Unknown App";
|
|
}
|
|
|
|
var binaryName = props["application.process.binary"] || "";
|
|
|
|
// Try binary name first (fixes Electron apps like vesktop)
|
|
if (binaryName) {
|
|
var binParts = binaryName.split("/");
|
|
if (binParts.length > 0) {
|
|
var binName = binParts[binParts.length - 1].toLowerCase();
|
|
var entry = ThemeIcons.findAppEntry(binName);
|
|
// Only use entry if it's actually related to binary name
|
|
if (entry && entry.name && isValidMatch(binName, entry))
|
|
return entry.name;
|
|
}
|
|
}
|
|
|
|
var computedAppName = props["application.name"] || "";
|
|
var mediaName = props["media.name"] || "";
|
|
var appId = props["application.id"] || "";
|
|
|
|
if (appId) {
|
|
var entry = ThemeIcons.findAppEntry(appId);
|
|
if (entry && entry.name && isValidMatch(appId, entry))
|
|
return entry.name;
|
|
if (!computedAppName) {
|
|
var parts = appId.split(".");
|
|
if (parts.length > 0 && parts[0])
|
|
computedAppName = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
|
}
|
|
}
|
|
|
|
if (!computedAppName && binaryName) {
|
|
var binParts = binaryName.split("/");
|
|
if (binParts.length > 0 && binParts[binParts.length - 1])
|
|
computedAppName = binParts[binParts.length - 1].charAt(0).toUpperCase() + binParts[binParts.length - 1].slice(1);
|
|
}
|
|
|
|
var result = computedAppName || mediaTitle || mediaName || binaryName || desc || name;
|
|
|
|
if (!result || result === "" || result === "Unknown App") {
|
|
if (name) {
|
|
var nameParts = name.split(/[-_]/);
|
|
if (nameParts.length > 0 && nameParts[0])
|
|
result = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1);
|
|
}
|
|
}
|
|
|
|
return result || "Unknown App";
|
|
}
|
|
|
|
PwObjectTracker {
|
|
objects: appBox.modelData ? [appBox.modelData] : []
|
|
}
|
|
|
|
PwNodePeakMonitor {
|
|
id: peak
|
|
node: appBox.modelData
|
|
}
|
|
|
|
readonly property bool isCaptureStream: {
|
|
if (!modelData || !modelData.properties)
|
|
return false;
|
|
const props = modelData.properties;
|
|
// Exclude capture streams - check for stream.capture.sink property
|
|
if (props["stream.capture.sink"] !== undefined) {
|
|
return true;
|
|
}
|
|
const mediaClass = props["media.class"] || "";
|
|
// Exclude Stream/Input (capture) but allow Stream/Output (playback)
|
|
if (mediaClass.includes("Capture") || mediaClass === "Stream/Input" || mediaClass === "Stream/Input/Audio") {
|
|
return true;
|
|
}
|
|
const mediaRole = props["media.role"] || "";
|
|
if (mediaRole === "Capture") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
RowLayout {
|
|
id: layoutVolume
|
|
anchors.fill: parent
|
|
spacing: 15
|
|
|
|
IconImage {
|
|
id: icon
|
|
property string iconPath1: Quickshell.iconPath(DesktopEntries.byId(appBox.modelData.name).icon);
|
|
property string iconPath2: Quickshell.iconPath(DesktopEntries.byId(appBox.appName).icon);
|
|
source: iconPath1 !== "" ? iconPath1 : iconPath2
|
|
Layout.alignment: Qt.AlignVCenter
|
|
implicitSize: 42
|
|
|
|
StateLayer {
|
|
radius: 1000
|
|
onClicked: {
|
|
appBox.modelData.audio.muted = !appBox.modelData.audio.muted;
|
|
}
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
|
|
Layout.fillHeight: true
|
|
|
|
TextMetrics {
|
|
id: metrics
|
|
text: appBox.appName
|
|
elide: Text.ElideRight
|
|
elideWidth: root.width - 50
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
CustomText {
|
|
text: metrics.elidedText
|
|
elide: Text.ElideRight
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
|
|
}
|
|
|
|
CustomText {
|
|
text: qsTr("%1").arg(appBox.modelData.audio.muted ? qsTr("Muted") : `${Math.round(appBox.modelData.audio.volume * 100)}%`);
|
|
font.bold: true
|
|
Layout.fillHeight: true
|
|
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
|
|
}
|
|
}
|
|
|
|
CustomMouseArea {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
|
|
Layout.bottomMargin: 5
|
|
implicitHeight: 10
|
|
CustomSlider {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
implicitHeight: 10
|
|
value: appBox.modelData.audio.volume
|
|
onMoved: {
|
|
Audio.setAppAudioVolume(appBox.modelData, value)
|
|
console.log(icon.iconPath1, icon.iconPath2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
component DevicesTab: ColumnLayout {
|
|
spacing: 12
|
|
|
|
ButtonGroup { id: sinks }
|
|
ButtonGroup { id: sources }
|
|
|
|
CustomText {
|
|
text: qsTr("Output device")
|
|
font.weight: 500
|
|
}
|
|
|
|
Repeater {
|
|
model: Audio.sinks
|
|
|
|
CustomRadioButton {
|
|
required property PwNode modelData
|
|
|
|
ButtonGroup.group: sinks
|
|
checked: Audio.sink?.id === modelData.id
|
|
onClicked: Audio.setAudioSink(modelData)
|
|
text: modelData.description
|
|
}
|
|
}
|
|
|
|
CustomText {
|
|
Layout.topMargin: 10
|
|
text: qsTr("Input device")
|
|
font.weight: 500
|
|
}
|
|
|
|
Repeater {
|
|
model: Audio.sources
|
|
|
|
CustomRadioButton {
|
|
required property PwNode modelData
|
|
|
|
ButtonGroup.group: sources
|
|
checked: Audio.source?.id === modelData.id
|
|
onClicked: Audio.setAudioSource(modelData)
|
|
text: modelData.description
|
|
}
|
|
}
|
|
}
|
|
}
|