From 8a551339cbfaf106ac7d6f1ca5230196be539167 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Mon, 8 Sep 2025 20:00:22 +0200 Subject: ... --- accounts/gkleen@sif/shell/default.nix | 6 + .../shell/quickshell-plugins/CMakeLists.txt | 24 +- .../shell/quickshell-plugins/KeePassXC.cpp | 18 + .../shell/quickshell-plugins/KeePassXC.hpp | 21 + .../shell/quickshell-plugins/Systemd.cpp | 16 + .../shell/quickshell-plugins/Systemd.hpp | 13 + .../shell/quickshell-plugins/default.nix | 8 + accounts/gkleen@sif/shell/quickshell/Bar.qml | 7 + accounts/gkleen@sif/shell/quickshell/Clock.qml | 3 +- .../gkleen@sif/shell/quickshell/Lockscreen.qml | 15 +- .../shell/quickshell/MaterialDesignIcon.qml | 24 ++ .../gkleen@sif/shell/quickshell/PipewireWidget.qml | 354 ++++++++++++++++ .../shell/quickshell/Services/GpgAgent.qml | 18 + accounts/gkleen@sif/shell/quickshell/VolumeOSD.qml | 129 ++++++ accounts/gkleen@sif/shell/quickshell/shell.qml | 2 + overlays/quickshell/default.nix | 2 + overlays/quickshell/lock-state-changed.patch | 12 + overlays/quickshell/pipewire.patch | 460 +++++++++++++++++++++ user-profiles/yt-dlp.nix | 4 + 19 files changed, 1130 insertions(+), 6 deletions(-) create mode 100644 accounts/gkleen@sif/shell/quickshell-plugins/KeePassXC.cpp create mode 100644 accounts/gkleen@sif/shell/quickshell-plugins/KeePassXC.hpp create mode 100644 accounts/gkleen@sif/shell/quickshell-plugins/Systemd.cpp create mode 100644 accounts/gkleen@sif/shell/quickshell-plugins/Systemd.hpp create mode 100644 accounts/gkleen@sif/shell/quickshell/MaterialDesignIcon.qml create mode 100644 accounts/gkleen@sif/shell/quickshell/PipewireWidget.qml create mode 100644 accounts/gkleen@sif/shell/quickshell/Services/GpgAgent.qml create mode 100644 accounts/gkleen@sif/shell/quickshell/VolumeOSD.qml create mode 100644 overlays/quickshell/lock-state-changed.patch create mode 100644 overlays/quickshell/pipewire.patch diff --git a/accounts/gkleen@sif/shell/default.nix b/accounts/gkleen@sif/shell/default.nix index 85e034d6..5025dd90 100644 --- a/accounts/gkleen@sif/shell/default.nix +++ b/accounts/gkleen@sif/shell/default.nix @@ -96,6 +96,12 @@ # "${config.programs.niri.package}/share/wayland-sessions/niri.desktop" ]; username = builtins.toJSON config.home.username; + mdi = builtins.toJSON (pkgs.fetchFromGitHub { + owner = "Templarian"; + repo = "MaterialDesign"; + rev = "2424e748e0cc63ab7b9c095a099b9fe239b737c0"; + hash = "sha256-QMGl7soAhErrrnY3aKOZpt49yebkSNzy10p/v5OaqQ0="; + }); }; }; }; diff --git a/accounts/gkleen@sif/shell/quickshell-plugins/CMakeLists.txt b/accounts/gkleen@sif/shell/quickshell-plugins/CMakeLists.txt index 2123ed35..a7e88fa7 100644 --- a/accounts/gkleen@sif/shell/quickshell-plugins/CMakeLists.txt +++ b/accounts/gkleen@sif/shell/quickshell-plugins/CMakeLists.txt @@ -92,7 +92,7 @@ endfunction() cmake_minimum_required(VERSION 3.20) project(custom LANGUAGES CXX) -find_package(Qt6 REQUIRED COMPONENTS Core Qml) +find_package(Qt6 REQUIRED COMPONENTS Core Qml DBus) qt_standard_project_setup(REQUIRES 6.6) @@ -102,16 +102,32 @@ qt6_add_qml_module(customplugin PLUGIN_TARGET customplugin ) -target_sources(customplugin PRIVATE - Chrono.cpp Chrono.hpp - FileSelector.cpp FileSelector.hpp +set_source_files_properties(org.keepassxc.KeePassXC.MainWindow.xml PROPERTIES + CLASSNAME DBusKeePassXC + NO_NAMESPACE TRUE ) +qt_add_dbus_interface(DBUS_INTERFACES + org.keepassxc.KeePassXC.MainWindow.xml + dbus_keepassxc +) + +include_directories(${CMAKE_SOURCE_DIR}/build) + target_compile_features(customplugin PUBLIC cxx_std_26) target_link_libraries(customplugin PRIVATE Qt6::Core Qt6::Qml + Qt6::DBus +) + +target_sources(customplugin PRIVATE + Chrono.cpp Chrono.hpp + FileSelector.cpp FileSelector.hpp + KeePassXC.cpp KeePassXC.hpp + Systemd.cpp Systemd.hpp + ${DBUS_INTERFACES} ) install_qml_module(customplugin) diff --git a/accounts/gkleen@sif/shell/quickshell-plugins/KeePassXC.cpp b/accounts/gkleen@sif/shell/quickshell-plugins/KeePassXC.cpp new file mode 100644 index 00000000..f6e4dd6e --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell-plugins/KeePassXC.cpp @@ -0,0 +1,18 @@ +#include "KeePassXC.hpp" + +#include + +KeePassXC::KeePassXC() { + this->service = new DBusKeePassXC(DBusKeePassXC::staticInterfaceName(), "/keepassxc", QDBusConnection::sessionBus(), this); +} +KeePassXC::~KeePassXC() { + if (this->service) + delete this->service; +} + +void KeePassXC::lockAllDatabases() { + if (!this->service) + return; + + this->service->lockAllDatabases(); +} diff --git a/accounts/gkleen@sif/shell/quickshell-plugins/KeePassXC.hpp b/accounts/gkleen@sif/shell/quickshell-plugins/KeePassXC.hpp new file mode 100644 index 00000000..c4cd71e0 --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell-plugins/KeePassXC.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "dbus_keepassxc.h" + +#include +#include + +class KeePassXC : public QObject { + Q_OBJECT; + QML_SINGLETON; + QML_ELEMENT; + +public: + explicit KeePassXC(); + ~KeePassXC(); + + Q_INVOKABLE void lockAllDatabases(); + +private: + DBusKeePassXC* service = nullptr; +}; diff --git a/accounts/gkleen@sif/shell/quickshell-plugins/Systemd.cpp b/accounts/gkleen@sif/shell/quickshell-plugins/Systemd.cpp new file mode 100644 index 00000000..9ccd8ba0 --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell-plugins/Systemd.cpp @@ -0,0 +1,16 @@ +#include "Systemd.hpp" + +#include +#include + +void Systemd::stopUserUnit(const QString& unit, const QString& mode) { + QDBusMessage m = QDBusMessage::createMethodCall( + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "StopUnit" + ); + m << unit; + m << mode; + QDBusConnection::sessionBus().send(m); +} diff --git a/accounts/gkleen@sif/shell/quickshell-plugins/Systemd.hpp b/accounts/gkleen@sif/shell/quickshell-plugins/Systemd.hpp new file mode 100644 index 00000000..883a96f3 --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell-plugins/Systemd.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +class Systemd : public QObject { + Q_OBJECT; + QML_SINGLETON; + QML_ELEMENT; + +public: + Q_INVOKABLE void stopUserUnit(const QString& unit, const QString& mode); +}; diff --git a/accounts/gkleen@sif/shell/quickshell-plugins/default.nix b/accounts/gkleen@sif/shell/quickshell-plugins/default.nix index fafea90e..33b76f61 100644 --- a/accounts/gkleen@sif/shell/quickshell-plugins/default.nix +++ b/accounts/gkleen@sif/shell/quickshell-plugins/default.nix @@ -3,11 +3,19 @@ , cmake , qt6 , fmt +, keepassxc +, systemd }: + stdenv.mkDerivation rec { name = "quickshell-custom"; src = ./.; + + prePatch = '' + cp ${keepassxc.src}/src/gui/org.keepassxc.KeePassXC.MainWindow.xml . + ''; + nativeBuildInputs = [ cmake qt6.wrapQtAppsHook ]; buildInputs = [ qt6.qtbase diff --git a/accounts/gkleen@sif/shell/quickshell/Bar.qml b/accounts/gkleen@sif/shell/quickshell/Bar.qml index 38225d74..3652af54 100644 --- a/accounts/gkleen@sif/shell/quickshell/Bar.qml +++ b/accounts/gkleen@sif/shell/quickshell/Bar.qml @@ -64,6 +64,13 @@ PanelWindow { anchors.verticalCenter: parent.verticalCenter spacing: 0 + PipewireWidget {} + + Item { + height: parent.height + width: 4 + } + SystemTray {} Item { diff --git a/accounts/gkleen@sif/shell/quickshell/Clock.qml b/accounts/gkleen@sif/shell/quickshell/Clock.qml index 382af168..4644d5e7 100644 --- a/accounts/gkleen@sif/shell/quickshell/Clock.qml +++ b/accounts/gkleen@sif/shell/quickshell/Clock.qml @@ -99,9 +99,10 @@ Item { id: clockTooltipContent margin: 8 - leftMargin: 0 ColumnLayout { + anchors.centerIn: parent + Text { id: yearLabel diff --git a/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml b/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml index cc82a275..8e739359 100644 --- a/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml +++ b/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml @@ -2,6 +2,9 @@ import Quickshell import Quickshell.Wayland import Quickshell.Io import Quickshell.Services.Pam +import Quickshell.Services.Mpris +import Custom as Custom +import qs.Services import QtQml Scope { @@ -38,9 +41,19 @@ Scope { WlSessionLock { id: lock - onLockedChanged: { + onLockStateChanged: { if (!locked && pam.active) pam.abort(); + + if (locked) { + Custom.KeePassXC.lockAllDatabases(); + Array.from(Mpris.players.values).forEach(player => { + if (player.canPause && player.isPlaying) + player.pause(); + }); + // Custom.Systemd.stopUserUnit("gpg-agent.service", "replace"); + GpgAgent.reloadAgent(); + } } WlSessionLockSurface { diff --git a/accounts/gkleen@sif/shell/quickshell/MaterialDesignIcon.qml b/accounts/gkleen@sif/shell/quickshell/MaterialDesignIcon.qml new file mode 100644 index 00000000..387dcc8b --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell/MaterialDesignIcon.qml @@ -0,0 +1,24 @@ +import QtQuick +import QtQuick.Effects + +Item { + id: icon + + required property string icon + property color color: "white" + + Image { + id: sourceImage + source: "file://" + @mdi@ + "/svg/" + icon.icon + ".svg" + anchors.fill: parent + + layer.enabled: true + layer.effect: MultiEffect { + id: effect + + brightness: 1 + colorization: 1 + colorizationColor: icon.color + } + } +} diff --git a/accounts/gkleen@sif/shell/quickshell/PipewireWidget.qml b/accounts/gkleen@sif/shell/quickshell/PipewireWidget.qml new file mode 100644 index 00000000..5bf0ac3a --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell/PipewireWidget.qml @@ -0,0 +1,354 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Fusion +import Quickshell +import Quickshell.Services.Pipewire +import Quickshell.Widgets + +Item { + height: parent.height + width: volumeIcon.width + 8 + anchors.verticalCenter: parent.verticalCenter + + PwObjectTracker { + objects: [Pipewire.defaultAudioSink] + } + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + if (!Pipewire.defaultAudioSink) + return; + Pipewire.defaultAudioSink.audio.muted = !Pipewire.defaultAudioSink.audio.muted; + } + + property real sensitivity: (1 / 20) / 120 + onWheel: event => { + if (!Pipewire.defaultAudioSink) + return; + Pipewire.defaultAudioSink.audio.volume += event.angleDelta.y * sensitivity; + } + + Rectangle { + id: volumeWidget + + anchors.fill: parent + color: { + if (widgetMouseArea.containsMouse) + return "#33808080"; + return "transparent"; + } + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: volumeIcon + + width: 16 + height: 16 + anchors.centerIn: parent + + icon: { + if (!Pipewire.defaultAudioSink || Pipewire.defaultAudioSink.audio.muted) + return "volume-off"; + if (Pipewire.defaultAudioSink.audio.volume <= 0.33) + return "volume-low"; + if (Pipewire.defaultAudioSink.audio.volume <= 0.67) + return "volume-medium"; + return "volume-high"; + } + color: "#555" + } + } + } + } + + Loader { + id: tooltipLoader + + active: false + + Connections { + target: widgetMouseArea + function onContainsMouseChanged() { + if (widgetMouseArea.containsMouse) + tooltipLoader.active = true; + } + } + + PwObjectTracker { + objects: Pipewire.devices + } + PwObjectTracker { + objects: Pipewire.nodes + } + + sourceComponent: PopupWindow { + id: tooltip + + property bool openPopup: false + property bool nextVisible: widgetMouseArea.containsMouse || tooltipMouseArea.containsMouse || openPopup + + anchor { + item: widgetMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: { + tooltip.visible = tooltip.nextVisible; + if (!tooltip.visible) + tooltipLoader.active = false; + } + } + + implicitWidth: tooltipContent.width + implicitHeight: tooltipContent.height + color: "black" + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + anchors.fill: parent + + WrapperItem { + id: tooltipContent + + margin: 8 + bottomMargin: 8 + Math.max(0, 200 - tooltipLayout.implicitHeight) + + GridLayout { + id: tooltipLayout + + columns: 4 + + Repeater { + model: Array.from(Pipewire.devices.values).filter(dev => dev.type == "Audio/Device") + + Item { + id: descItem + + required property var modelData + required property int index + + Layout.column: 0 + Layout.row: index + + implicitWidth: descText.contentWidth + implicitHeight: descText.contentHeight + + Text { + id: descText + + color: "white" + font.pointSize: 10 + font.family: "Fira Sans" + + text: descItem.modelData.description + } + } + } + + Repeater { + id: defaultSinkRepeater + + model: { + Array.from(Pipewire.devices.values) + .filter(dev => dev.type == "Audio/Device") + .map(device => Array.from(Pipewire.nodes.values).find(node => node.type == PwNodeType.AudioSink && node.device?.id == device.id )); + } + + Item { + id: defaultSinkItem + + required property var modelData + required property int index + + PwObjectTracker { + objects: [defaultSinkItem.modelData] + } + + Layout.column: 1 + Layout.row: index + + Layout.fillHeight: true + + implicitWidth: 16 + 8 + + WrapperMouseArea { + id: defaultSinkMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + Pipewire.preferredDefaultAudioSink = defaultSinkItem.modelData + } + + Rectangle { + id: defaultSinkWidget + + anchors.fill: parent + color: { + if (defaultSinkMouseArea.containsMouse) + return "#33808080"; + return "transparent"; + } + + MaterialDesignIcon { + width: 16 + height: 16 + anchors.centerIn: parent + + icon: { + if (defaultSinkItem.modelData?.id == Pipewire.defaultAudioSink?.id) + return "speaker"; + return "speaker-off"; + } + color: icon == "speaker" ? "white" : "#555" + } + } + } + } + } + + Repeater { + id: defaultSourceRepeater + + model: { + Array.from(Pipewire.devices.values) + .filter(dev => dev.type == "Audio/Device") + .map(device => Array.from(Pipewire.nodes.values).find(node => node.type == PwNodeType.AudioSource && node.device?.id == device.id )); + } + + Item { + id: defaultSourceItem + + required property var modelData + required property int index + + PwObjectTracker { + objects: [defaultSourceItem.modelData] + } + + Layout.column: 2 + Layout.row: index + + Layout.fillHeight: true + + implicitWidth: 16 + 8 + + WrapperMouseArea { + id: defaultSourceMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + Pipewire.preferredDefaultAudioSource = defaultSourceItem.modelData + } + + Rectangle { + id: defaultSourceWidget + + anchors.fill: parent + color: { + if (defaultSourceMouseArea.containsMouse) + return "#33808080"; + return "transparent"; + } + + MaterialDesignIcon { + width: 16 + height: 16 + anchors.centerIn: parent + + icon: { + if (defaultSourceItem.modelData?.id == Pipewire.defaultAudioSource?.id) + return "microphone"; + return "microphone-off"; + } + color: icon == "microphone" ? "white" : "#555" + } + } + } + } + } + + Repeater { + id: profileRepeater + + model: Array.from(Pipewire.devices.values).filter(dev => dev.type == "Audio/Device") + + Item { + id: profileItem + + required property var modelData + required property int index + + PwObjectTracker { + objects: [profileItem.modelData] + } + + Layout.column: 3 + Layout.row: index + + Layout.fillWidth: true + + implicitWidth: Math.max(profileBox.implicitWidth, 300) + implicitHeight: profileBox.height + + ComboBox { + id: profileBox + + model: profileItem.modelData.profiles + + textRole: "description" + valueRole: "index" + onActivated: profileItem.modelData.setProfile(currentValue) + + anchors.fill: parent + + implicitContentWidthPolicy: ComboBox.WidestText + + Connections { + target: profileItem.modelData + function onCurrentProfileChanged() { + profileBox.currentIndex = Array.from(profileItem.modelData.profiles).findIndex(profile => profile.index == profileItem.modelData.currentProfile); + } + } + Component.onCompleted: { + profileBox.currentIndex = Array.from(profileItem.modelData.profiles).findIndex(profile => profile.index == profileItem.modelData.currentProfile); + } + + Connections { + target: profileBox.popup + function onVisibleChanged() { + tooltip.openPopup = profileBox.popup.visible + } + } + } + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@sif/shell/quickshell/Services/GpgAgent.qml b/accounts/gkleen@sif/shell/quickshell/Services/GpgAgent.qml new file mode 100644 index 00000000..3de69535 --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell/Services/GpgAgent.qml @@ -0,0 +1,18 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + Socket { + id: agentSocket + connected: true + path: `${Quickshell.env("XDG_RUNTIME_DIR")}/gnupg/S.gpg-agent` + } + + function reloadAgent() { + agentSocket.write("RELOADAGENT\n") + } +} diff --git a/accounts/gkleen@sif/shell/quickshell/VolumeOSD.qml b/accounts/gkleen@sif/shell/quickshell/VolumeOSD.qml new file mode 100644 index 00000000..3a78d91b --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell/VolumeOSD.qml @@ -0,0 +1,129 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import Quickshell.Widgets + +Scope { + id: root + + property bool show: false + property bool inhibited: true + + PwObjectTracker { + objects: [ Pipewire.defaultAudioSink ] + } + + Connections { + target: Pipewire.defaultAudioSink?.audio + + function onVolumeChanged() { + root.show = true; + hideTimer.reset(); + } + function onMutedChanged() { + root.show = true; + hideTimer.reset(); + } + } + + onShowChanged: { + if (show) + hideTimer.restart(); + } + + Timer { + id: hideTimer + interval: 2000 + onTriggered: root.show = false + } + + Timer { + id: startInhibit + interval: 100 + running: true + onTriggered: root.inhibited = false; + } + + LazyLoader { + active: root.show && !root.inhibited + + Variants { + model: Quickshell.screens + + delegate: Scope { + id: screenScope + + required property var modelData + + PanelWindow { + id: window + + screen: screenScope.modelData + + anchors.top: true + margins.top: (screen.height - window.height) / 2 + exclusiveZone: 0 + + implicitWidth: 400 + implicitHeight: 50 + + mask: Region {} + + color: "transparent" + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.75) + } + + RowLayout { + id: layout + + anchors.centerIn: parent + + height: 50 - 8*2 + width: 400 - 8*2 + + MaterialDesignIcon { + id: volumeIcon + + implicitWidth: parent.height + implicitHeight: parent.height + + icon: { + if (!Pipewire.defaultAudioSink || Pipewire.defaultAudioSink.audio.muted) + return "volume-off"; + if (Pipewire.defaultAudioSink.audio.volume <= 0.33) + return "volume-low"; + if (Pipewire.defaultAudioSink.audio.volume <= 0.67) + return "volume-medium"; + return "volume-high"; + } + } + + Rectangle { + Layout.fillWidth: true + + implicitHeight: 10 + + color: "#50ffffff" + + Rectangle { + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + + color: Pipewire.defaultAudioSink?.audio.muted ? "#70ffffff" : "white" + + implicitWidth: parent.width * (Pipewire.defaultAudioSink?.audio.volume ?? 0) + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@sif/shell/quickshell/shell.qml b/accounts/gkleen@sif/shell/quickshell/shell.qml index 1da9457d..3657f77f 100644 --- a/accounts/gkleen@sif/shell/quickshell/shell.qml +++ b/accounts/gkleen@sif/shell/quickshell/shell.qml @@ -41,4 +41,6 @@ ShellRoot { } Lockscreen {} + + VolumeOSD {} } diff --git a/overlays/quickshell/default.nix b/overlays/quickshell/default.nix index ac722010..622d69a3 100644 --- a/overlays/quickshell/default.nix +++ b/overlays/quickshell/default.nix @@ -3,6 +3,8 @@ quickshell = prev.quickshell.overrideAttrs (oldAttrs: { patches = (oldAttrs.patches or []) ++ [ ./greetd-response.patch + ./lock-state-changed.patch + ./pipewire.patch ]; }); } diff --git a/overlays/quickshell/lock-state-changed.patch b/overlays/quickshell/lock-state-changed.patch new file mode 100644 index 00000000..4be273fa --- /dev/null +++ b/overlays/quickshell/lock-state-changed.patch @@ -0,0 +1,12 @@ +diff --git i/src/wayland/session_lock.cpp w/src/wayland/session_lock.cpp +index 0ecf9ec..3dbd19b 100644 +--- i/src/wayland/session_lock.cpp ++++ w/src/wayland/session_lock.cpp +@@ -127,6 +127,7 @@ void WlSessionLock::realizeLockTarget(WlSessionLock* old) { + this->updateSurfaces(false); + + if (!this->manager->lock()) this->lockTarget = false; ++ emit this->lockStateChanged(); + + this->updateSurfaces(true, old); + } else { diff --git a/overlays/quickshell/pipewire.patch b/overlays/quickshell/pipewire.patch new file mode 100644 index 00000000..33025d8b --- /dev/null +++ b/overlays/quickshell/pipewire.patch @@ -0,0 +1,460 @@ +diff --git i/src/services/pipewire/device.cpp w/src/services/pipewire/device.cpp +index 616e7d0..0c55008 100644 +--- i/src/services/pipewire/device.cpp ++++ w/src/services/pipewire/device.cpp +@@ -3,6 +3,7 @@ + #include + #include + #include ++#include + + #include + #include +@@ -19,6 +20,8 @@ + #include + #include + #include ++#include ++#include + + #include "../../core/logcat.hpp" + #include "core.hpp" +@@ -46,6 +49,25 @@ void PwDevice::unbindHooks() { + this->mWaitingForDevice = false; + } + ++void PwDevice::initProps(const spa_dict* props) { ++ if (const auto* deviceName = spa_dict_lookup(props, SPA_KEY_DEVICE_NAME)) { ++ this->name = deviceName; ++ } ++ ++ if (const auto* deviceDesc = spa_dict_lookup(props, SPA_KEY_DEVICE_DESCRIPTION)) { ++ this->description = deviceDesc; ++ } ++ ++ if (const auto* deviceNick = spa_dict_lookup(props, SPA_KEY_DEVICE_NICK)) { ++ this->nick = deviceNick; ++ } ++ ++ if (const auto* mediaClass = spa_dict_lookup(props, SPA_KEY_MEDIA_CLASS)) { ++ this->type = mediaClass; ++ } ++} ++ ++ + const pw_device_events PwDevice::EVENTS = { + .version = PW_VERSION_DEVICE_EVENTS, + .info = &PwDevice::onInfo, +@@ -71,6 +93,11 @@ void PwDevice::onInfo(void* data, const pw_device_info* info) { + } + + break; ++ } else if (param.id == SPA_PARAM_EnumProfile && param.flags & SPA_PARAM_INFO_READ) { ++ self->validProfiles.clear(); ++ pw_device_enum_params(self->proxy(), 0, param.id, 0, UINT32_MAX, nullptr); ++ } else if (param.id == SPA_PARAM_Profile && param.flags & SPA_PARAM_INFO_READ) { ++ pw_device_enum_params(self->proxy(), 0, param.id, 0, UINT32_MAX, nullptr); + } + } + } +@@ -97,6 +124,15 @@ void PwDevice::onParam( + } + + self->addDeviceIndexPairs(param); ++ } else if (id == SPA_PARAM_EnumProfile) { ++ PwProfile profile = PwProfile::parseSpaPod(param); ++ self->profilesUpdated = true; ++ self->profiles.insertOrAssign(profile.index, profile); ++ self->validProfiles.insert(profile.index); ++ } else if (id == SPA_PARAM_Profile) { ++ PwProfile profile = PwProfile::parseSpaPod(param); ++ self->currentProfileUpdated = true; ++ self->currentProfile = profile; + } + } + +@@ -145,6 +181,21 @@ void PwDevice::polled() { + return false; + }); + } ++ if (this->profilesUpdated) { ++ this->profiles.removeIf([&](const std::pair& entry) { ++ return !this->validProfiles.contains(entry.first); ++ }); ++ this->profilesUpdated = false; ++ QList profiles = this->profiles.values(); ++ std::sort(profiles.begin(), profiles.end(), [](const PwProfile& a, const PwProfile& b) { return a.index < b.index; }); ++ emit this->profilesChanged(profiles); ++ } ++ if (this->currentProfileUpdated) { ++ this->currentProfileUpdated = false; ++ if (this->currentProfile) { ++ emit this->currentProfileChanged(*this->currentProfile); ++ } ++ } + } + + bool PwDevice::setVolumes(qint32 routeDevice, const QVector& volumes) { +@@ -182,6 +233,15 @@ bool PwDevice::setMuted(qint32 routeDevice, bool muted) { + }); + } + ++void PwDevice::setProfile(qint32 profileIndex) { ++ auto buffer = std::array(); ++ auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size()); ++ auto* pod = spa_pod_builder_add_object(&builder, ++ SPA_TYPE_OBJECT_ParamProfile, SPA_PARAM_Profile, ++ SPA_PARAM_PROFILE_index, SPA_POD_Int(profileIndex)); ++ pw_device_set_param(this->proxy(), SPA_PARAM_Profile, 0, static_cast(pod)); ++} ++ + void PwDevice::waitForDevice() { this->mWaitingForDevice = true; } + bool PwDevice::waitingForDevice() const { return this->mWaitingForDevice; } + +@@ -222,4 +282,24 @@ bool PwDevice::setRouteProps( + return true; + } + ++PwProfile PwProfile::parseSpaPod(const spa_pod* param) { ++ PwProfile profile; ++ ++ const auto* indexProp = spa_pod_find_prop(param, nullptr, SPA_PARAM_PROFILE_index); ++ const auto* descProp = spa_pod_find_prop(param, nullptr, SPA_PARAM_PROFILE_description); ++ const auto* nameProp = spa_pod_find_prop(param, nullptr, SPA_PARAM_PROFILE_name); ++ ++ spa_pod_get_int(&indexProp->value, &profile.index); ++ ++ const char* desc_cstr = nullptr; ++ spa_pod_get_string(&descProp->value, &desc_cstr); ++ profile.description = QString(desc_cstr); ++ ++ const char* name_cstr = nullptr; ++ spa_pod_get_string(&nameProp->value, &name_cstr); ++ profile.name = QString(name_cstr); ++ ++ return profile; ++} ++ + } // namespace qs::service::pipewire +diff --git i/src/services/pipewire/device.hpp w/src/services/pipewire/device.hpp +index 1a1f705..ee64858 100644 +--- i/src/services/pipewire/device.hpp ++++ w/src/services/pipewire/device.hpp +@@ -1,6 +1,7 @@ + #pragma once + + #include ++#include + + #include + #include +@@ -17,6 +18,20 @@ + + namespace qs::service::pipewire { + ++struct PwProfile { ++ Q_GADGET; ++ Q_PROPERTY(qint32 index MEMBER index) ++ Q_PROPERTY(QString description MEMBER description) ++ Q_PROPERTY(QString name MEMBER name) ++ ++public: ++ qint32 index; ++ QString description; ++ QString name; ++ ++ static PwProfile parseSpaPod(const spa_pod* param); ++}; ++ + class PwDevice; + + class PwDevice: public PwBindable { +@@ -25,6 +40,12 @@ class PwDevice: public PwBindable& volumes); + bool setMuted(qint32 routeDevice, bool muted); +@@ -32,9 +53,16 @@ public: + void waitForDevice(); + [[nodiscard]] bool waitingForDevice() const; + ++ void setProfile(qint32 profileIndex); ++ ++ QHash profiles; ++ std::optional currentProfile; ++ + signals: + void deviceReady(); + void routeVolumesChanged(qint32 routeDevice, const PwVolumeProps& volumeProps); ++ void profilesChanged(QList profiles); ++ void currentProfileChanged(PwProfile profile); + + private slots: + void polled(); +@@ -49,6 +77,11 @@ private: + QList stagingIndexes; + void addDeviceIndexPairs(const spa_pod* param); + ++ bool profilesUpdated = false; ++ QSet validProfiles; ++ ++ bool currentProfileUpdated = false; ++ + bool + setRouteProps(qint32 routeDevice, const std::function& propsCallback); + +diff --git i/src/services/pipewire/qml.cpp w/src/services/pipewire/qml.cpp +index 9efb17e..921d12a 100644 +--- i/src/services/pipewire/qml.cpp ++++ w/src/services/pipewire/qml.cpp +@@ -9,6 +9,9 @@ + #include + #include + ++#include ++#include ++ + #include "../../core/model.hpp" + #include "connection.hpp" + #include "defaults.hpp" +@@ -54,6 +57,12 @@ Pipewire::Pipewire(QObject* parent): QObject(parent) { + + QObject::connect(&connection->registry, &PwRegistry::nodeAdded, this, &Pipewire::onNodeAdded); + ++ for (auto* device: connection->registry.devices.values()) { ++ this->onDeviceAdded(device); ++ } ++ ++ QObject::connect(&connection->registry, &PwRegistry::deviceAdded, this, &Pipewire::onDeviceAdded); ++ + for (auto* link: connection->registry.links.values()) { + this->onLinkAdded(link); + } +@@ -123,6 +132,19 @@ void Pipewire::onNodeRemoved(QObject* object) { + this->mNodes.removeObject(iface); + } + ++ObjectModel* Pipewire::devices() { return &this->mDevices; } ++ ++void Pipewire::onDeviceAdded(PwDevice* device) { ++ auto* iface = PwDeviceIface::instance(device); ++ QObject::connect(iface, &QObject::destroyed, this, &Pipewire::onDeviceRemoved); ++ this->mDevices.insertObject(iface); ++} ++ ++void Pipewire::onDeviceRemoved(QObject* object) { ++ auto* iface = static_cast(object); // NOLINT ++ this->mDevices.removeObject(iface); ++} ++ + ObjectModel* Pipewire::links() { return &this->mLinks; } + + void Pipewire::onLinkAdded(PwLink* link) { +@@ -357,6 +379,8 @@ QVariantMap PwNodeIface::properties() const { + + PwNodeAudioIface* PwNodeIface::audio() const { return this->audioIface; } + ++PwDeviceIface* PwNodeIface::device() const { return PwDeviceIface::instance(this->mNode->device); } ++ + PwNodeIface* PwNodeIface::instance(PwNode* node) { + if (node == nullptr) return nullptr; + +@@ -481,4 +505,42 @@ void PwObjectTracker::objectDestroyed(QObject* object) { + emit this->objectsChanged(); + } + ++PwDeviceIface::PwDeviceIface(PwDevice* device): PwObjectIface(device), mDevice(device) { ++ QObject::connect(device, &PwDevice::profilesChanged, this, &PwDeviceIface::deviceProfilesChanged); ++ QObject::connect(device, &PwDevice::currentProfileChanged, this, &PwDeviceIface::deviceCurrentProfileChanged); ++} ++ ++void PwDeviceIface::deviceProfilesChanged(QList) { emit this->profilesChanged(); } ++void PwDeviceIface::deviceCurrentProfileChanged(PwProfile) { emit this->currentProfileChanged(); } ++ ++quint32 PwDeviceIface::id() const { return this->mDevice->id; } ++QString PwDeviceIface::name() const { return this->mDevice->name; } ++QString PwDeviceIface::description() const { return this->mDevice->description; } ++QString PwDeviceIface::nickname() const { return this->mDevice->nick; } ++QString PwDeviceIface::type() const { return this->mDevice->type; } ++QList PwDeviceIface::profiles() const { ++ QList profiles = this->mDevice->profiles.values(); ++ std::sort(profiles.begin(), profiles.end(), [](const PwProfile& a, const PwProfile& b) { return a.index < b.index; }); ++ return profiles; ++} ++qint32 PwDeviceIface::currentProfile() const { return this->mDevice->currentProfile->index; } ++ ++PwDeviceIface* PwDeviceIface::instance(PwDevice* device) { ++ if (device == nullptr) return nullptr; ++ ++ auto v = device->property("iface"); ++ if (v.canConvert()) { ++ return v.value(); ++ } ++ ++ auto* instance = new PwDeviceIface(device); ++ device->setProperty("iface", QVariant::fromValue(instance)); ++ ++ return instance; ++} ++ ++void PwDeviceIface::setProfile(qint32 profileIndex) { ++ this->mDevice->setProfile(profileIndex); ++} ++ + } // namespace qs::service::pipewire +diff --git i/src/services/pipewire/qml.hpp w/src/services/pipewire/qml.hpp +index e3489a1..e5e1891 100644 +--- i/src/services/pipewire/qml.hpp ++++ w/src/services/pipewire/qml.hpp +@@ -12,11 +12,13 @@ + #include "../../core/model.hpp" + #include "link.hpp" + #include "node.hpp" ++#include "device.hpp" + #include "registry.hpp" + + namespace qs::service::pipewire { + + class PwNodeIface; ++class PwDeviceIface; + class PwLinkIface; + class PwLinkGroupIface; + +@@ -65,6 +67,8 @@ class Pipewire: public QObject { + /// - @@PwNode.audio - if non null the node is an audio node. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* nodes READ nodes CONSTANT); ++ QSDOC_TYPE_OVERRIDE(ObjectModel*); ++ Q_PROPERTY(UntypedObjectModel* devices READ devices CONSTANT); + /// All links present in pipewire. + /// + /// Links connect pipewire nodes to each other, and can be used to determine +@@ -134,6 +138,7 @@ public: + explicit Pipewire(QObject* parent = nullptr); + + [[nodiscard]] ObjectModel* nodes(); ++ [[nodiscard]] ObjectModel* devices(); + [[nodiscard]] ObjectModel* links(); + [[nodiscard]] ObjectModel* linkGroups(); + +@@ -159,7 +164,9 @@ signals: + + private slots: + void onNodeAdded(PwNode* node); ++ void onDeviceAdded(PwDevice* node); + void onNodeRemoved(QObject* object); ++ void onDeviceRemoved(QObject* object); + void onLinkAdded(PwLink* link); + void onLinkRemoved(QObject* object); + void onLinkGroupAdded(PwLinkGroup* group); +@@ -167,6 +174,7 @@ private slots: + + private: + ObjectModel mNodes {this}; ++ ObjectModel mDevices {this}; + ObjectModel mLinks {this}; + ObjectModel mLinkGroups {this}; + }; +@@ -315,6 +323,7 @@ class PwNodeIface: public PwObjectIface { + /// > [!NOTE] The node may be used before it is fully bound, but some data + /// > may be missing or incorrect. + Q_PROPERTY(bool ready READ isReady NOTIFY readyChanged); ++ Q_PROPERTY(qs::service::pipewire::PwDeviceIface* device READ device CONSTANT); + QML_NAMED_ELEMENT(PwNode); + QML_UNCREATABLE("PwNodes cannot be created directly"); + +@@ -332,6 +341,7 @@ public: + [[nodiscard]] PwNodeType::Flags type() const; + [[nodiscard]] QVariantMap properties() const; + [[nodiscard]] PwNodeAudioIface* audio() const; ++ [[nodiscard]] PwDeviceIface* device() const; + + static PwNodeIface* instance(PwNode* node); + +@@ -344,6 +354,44 @@ private: + PwNodeAudioIface* audioIface = nullptr; + }; + ++class PwDeviceIface: public PwObjectIface { ++ Q_OBJECT; ++ Q_PROPERTY(quint32 id READ id CONSTANT); ++ Q_PROPERTY(QString name READ name CONSTANT); ++ Q_PROPERTY(QString description READ description CONSTANT); ++ Q_PROPERTY(QString nickname READ nickname CONSTANT); ++ Q_PROPERTY(QString type READ type CONSTANT); ++ Q_PROPERTY(QList profiles READ profiles NOTIFY profilesChanged); ++ Q_PROPERTY(qint32 currentProfile READ currentProfile NOTIFY currentProfileChanged); ++ ++ QML_NAMED_ELEMENT(PwDevice); ++ QML_UNCREATABLE("PwDevices cannot be created directly"); ++ ++signals: ++ void profilesChanged(); ++ void currentProfileChanged(); ++ ++public: ++ explicit PwDeviceIface(PwDevice* node); ++ ++ [[nodiscard]] quint32 id() const; ++ [[nodiscard]] QString name() const; ++ [[nodiscard]] QString description() const; ++ [[nodiscard]] QString nickname() const; ++ [[nodiscard]] QString type() const; ++ QList profiles() const; ++ qint32 currentProfile() const; ++ ++ Q_INVOKABLE void setProfile(qint32 profileIndex); ++ ++ static PwDeviceIface* instance(PwDevice* node); ++private: ++ PwDevice* mDevice; ++ ++ void deviceProfilesChanged(QList profiles); ++ void deviceCurrentProfileChanged(PwProfile profile); ++}; ++ + ///! A connection between pipewire nodes. + /// Note that there is one link per *channel* of a connection between nodes. + /// You usually want @@PwLinkGroup. +diff --git i/src/services/pipewire/registry.cpp w/src/services/pipewire/registry.cpp +index c08fc1d..50c6d7a 100644 +--- i/src/services/pipewire/registry.cpp ++++ w/src/services/pipewire/registry.cpp +@@ -196,6 +196,7 @@ void PwRegistry::onGlobal( + device->initProps(props); + + self->devices.emplace(id, device); ++ emit self->deviceAdded(device); + } + } + +@@ -211,6 +212,9 @@ void PwRegistry::onGlobalRemoved(void* data, quint32 id) { + } else if (auto* node = self->nodes.value(id)) { + self->nodes.remove(id); + node->safeDestroy(); ++ } else if (auto* device = self->devices.value(id)) { ++ self->devices.remove(id); ++ device->safeDestroy(); + } + } + +diff --git i/src/services/pipewire/registry.hpp w/src/services/pipewire/registry.hpp +index 8473f04..87e0766 100644 +--- i/src/services/pipewire/registry.hpp ++++ w/src/services/pipewire/registry.hpp +@@ -132,6 +132,7 @@ public: + + signals: + void nodeAdded(PwNode* node); ++ void deviceAdded(PwDevice* node); + void linkAdded(PwLink* link); + void linkGroupAdded(PwLinkGroup* group); + void metadataAdded(PwMetadata* metadata); diff --git a/user-profiles/yt-dlp.nix b/user-profiles/yt-dlp.nix index f59a71b9..9e30bba8 100644 --- a/user-profiles/yt-dlp.nix +++ b/user-profiles/yt-dlp.nix @@ -11,6 +11,10 @@ "best[width<=2560][height<=1440][fps<=60][vcodec!*=av01][vcodec!*=avc1]" "bestvideo*[vcodec!*=av01][vcodec!*=avc1]+bestaudio" "best[vcodec!*=av01][vcodec!*=avc1]" + "bestvideo*[width<=2560][height<=1440][fps<=60]+bestaudio" + "best[width<=2560][height<=1440][fps<=60]" + "bestvideo*+bestaudio" + "best" ]; embed-subs = true; embed-thumbnail = true; -- cgit v1.2.3