summaryrefslogtreecommitdiff
path: root/overlays/quickshell
diff options
context:
space:
mode:
Diffstat (limited to 'overlays/quickshell')
-rw-r--r--overlays/quickshell/close-stdin.patch13
-rw-r--r--overlays/quickshell/default.nix26
-rw-r--r--overlays/quickshell/io.patch13
-rw-r--r--overlays/quickshell/lock-state-changed.patch12
-rw-r--r--overlays/quickshell/notify-session-unlock.patch15
-rw-r--r--overlays/quickshell/pipewire.patch491
6 files changed, 570 insertions, 0 deletions
diff --git a/overlays/quickshell/close-stdin.patch b/overlays/quickshell/close-stdin.patch
new file mode 100644
index 00000000..230c602c
--- /dev/null
+++ b/overlays/quickshell/close-stdin.patch
@@ -0,0 +1,13 @@
1diff --git i/src/io/process.cpp w/src/io/process.cpp
2index 6055e2c..f528438 100644
3--- i/src/io/process.cpp
4+++ w/src/io/process.cpp
5@@ -210,7 +210,7 @@ void Process::startProcessIfReady() {
6
7 if (this->mStdoutParser == nullptr) this->process->closeReadChannel(QProcess::StandardOutput);
8 if (this->mStderrParser == nullptr) this->process->closeReadChannel(QProcess::StandardError);
9- if (!this->mStdinEnabled) this->process->closeWriteChannel();
10+ if (!this->mStdinEnabled) this->process->setStandardInputFile(QProcess::nullDevice());
11
12 this->setupEnvironment(this->process);
13 this->process->start(cmd, args);
diff --git a/overlays/quickshell/default.nix b/overlays/quickshell/default.nix
new file mode 100644
index 00000000..38c78883
--- /dev/null
+++ b/overlays/quickshell/default.nix
@@ -0,0 +1,26 @@
1{ final, prev, sources, ... }:
2{
3 quickshell = prev.quickshell.overrideAttrs (oldAttrs: {
4 inherit (sources.quickshell) version src;
5
6 buildInputs = (oldAttrs.buildInputs or []) ++ [
7 (prev.lib.getDev final.polkit)
8 (final.cpptrace.overrideAttrs (oldAttrs: {
9 buildInputs = (oldAttrs.buildInputs or []) ++ [
10 (prev.lib.getDev final.libunwind)
11 ];
12 cmakeFlags = (oldAttrs.cmakeFlags or []) ++ [
13 (prev.lib.cmakeBool "CPPTRACE_UNWIND_WITH_LIBUNWIND" true)
14 ];
15 }))
16 ];
17
18 patches = (oldAttrs.patches or []) ++ [
19 # ./lock-state-changed.patch
20 # ./notify-session-unlock.patch
21 ./pipewire.patch
22 ./io.patch
23 ./close-stdin.patch
24 ];
25 });
26}
diff --git a/overlays/quickshell/io.patch b/overlays/quickshell/io.patch
new file mode 100644
index 00000000..961bdcaf
--- /dev/null
+++ b/overlays/quickshell/io.patch
@@ -0,0 +1,13 @@
1diff --git i/src/io/socket.cpp w/src/io/socket.cpp
2index 371f687..d12eaeb 100644
3--- i/src/io/socket.cpp
4+++ w/src/io/socket.cpp
5@@ -66,7 +66,7 @@ void Socket::onSocketDisconnected() {
6 }
7
8 void Socket::onSocketError(QLocalSocket::LocalSocketError error) {
9- qCWarning(logSocket) << "Socket error for" << this << error;
10+ // qCWarning(logSocket) << "Socket error for" << this << error;
11 emit this->error(error);
12 }
13
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 @@
1diff --git i/src/wayland/session_lock.cpp w/src/wayland/session_lock.cpp
2index 0ecf9ec..3dbd19b 100644
3--- i/src/wayland/session_lock.cpp
4+++ w/src/wayland/session_lock.cpp
5@@ -127,6 +127,7 @@ void WlSessionLock::realizeLockTarget(WlSessionLock* old) {
6 this->updateSurfaces(false);
7
8 if (!this->manager->lock()) this->lockTarget = false;
9+ emit this->lockStateChanged();
10
11 this->updateSurfaces(true, old);
12 } else {
diff --git a/overlays/quickshell/notify-session-unlock.patch b/overlays/quickshell/notify-session-unlock.patch
new file mode 100644
index 00000000..d0b835eb
--- /dev/null
+++ b/overlays/quickshell/notify-session-unlock.patch
@@ -0,0 +1,15 @@
1diff --git i/src/wayland/session_lock.cpp w/src/wayland/session_lock.cpp
2index 43d80e2..742d683 100644
3--- i/src/wayland/session_lock.cpp
4+++ w/src/wayland/session_lock.cpp
5@@ -179,8 +179,9 @@ void WlSessionLock::unlock() {
6
7 this->surfaces.clear();
8
9+ auto wasLocked = this->isLocked();
10 if (this->manager) this->manager->unlock();
11- if (this->isLocked()) emit this->lockStateChanged();
12+ if (wasLocked != this->isLocked()) emit this->lockStateChanged();
13 }
14
15 void WlSessionLock::onScreensChanged() {
diff --git a/overlays/quickshell/pipewire.patch b/overlays/quickshell/pipewire.patch
new file mode 100644
index 00000000..8c5a4c1e
--- /dev/null
+++ b/overlays/quickshell/pipewire.patch
@@ -0,0 +1,491 @@
1diff --git c/src/services/pipewire/device.cpp i/src/services/pipewire/device.cpp
2index 61079a1..7246a67 100644
3--- c/src/services/pipewire/device.cpp
4+++ i/src/services/pipewire/device.cpp
5@@ -3,6 +3,7 @@
6 #include <cstdint>
7 #include <functional>
8 #include <utility>
9+#include <algorithm>
10
11 #include <pipewire/device.h>
12 #include <qcontainerfwd.h>
13@@ -19,6 +20,10 @@
14 #include <spa/pod/pod.h>
15 #include <spa/pod/vararg.h>
16 #include <spa/utils/type.h>
17+#include <spa/monitor/device.h>
18+#include <spa/utils/keys.h>
19+#include <spa/pod/parser.h>
20+#include <spa/pod/iter.h>
21
22 #include "../../core/logcat.hpp"
23 #include "core.hpp"
24@@ -46,6 +51,25 @@ void PwDevice::unbindHooks() {
25 this->mWaitingForDevice = false;
26 }
27
28+void PwDevice::initProps(const spa_dict* props) {
29+ if (const auto* deviceName = spa_dict_lookup(props, SPA_KEY_DEVICE_NAME)) {
30+ this->name = deviceName;
31+ }
32+
33+ if (const auto* deviceDesc = spa_dict_lookup(props, SPA_KEY_DEVICE_DESCRIPTION)) {
34+ this->description = deviceDesc;
35+ }
36+
37+ if (const auto* deviceNick = spa_dict_lookup(props, SPA_KEY_DEVICE_NICK)) {
38+ this->nick = deviceNick;
39+ }
40+
41+ if (const auto* mediaClass = spa_dict_lookup(props, SPA_KEY_MEDIA_CLASS)) {
42+ this->type = mediaClass;
43+ }
44+}
45+
46+
47 const pw_device_events PwDevice::EVENTS = {
48 .version = PW_VERSION_DEVICE_EVENTS,
49 .info = &PwDevice::onInfo,
50@@ -71,6 +95,11 @@ void PwDevice::onInfo(void* data, const pw_device_info* info) {
51 }
52
53 break;
54+ } else if (param.id == SPA_PARAM_EnumProfile && param.flags & SPA_PARAM_INFO_READ) {
55+ self->validProfiles.clear();
56+ pw_device_enum_params(self->proxy(), 0, param.id, 0, UINT32_MAX, nullptr);
57+ } else if (param.id == SPA_PARAM_Profile && param.flags & SPA_PARAM_INFO_READ) {
58+ pw_device_enum_params(self->proxy(), 0, param.id, 0, UINT32_MAX, nullptr);
59 }
60 }
61 }
62@@ -97,6 +126,15 @@ void PwDevice::onParam(
63 }
64
65 self->addDeviceIndexPairs(param);
66+ } else if (id == SPA_PARAM_EnumProfile) {
67+ PwProfile profile = PwProfile::parseSpaPod(param);
68+ self->profilesUpdated = true;
69+ self->profiles.insertOrAssign(profile.index, profile);
70+ self->validProfiles.insert(profile.index);
71+ } else if (id == SPA_PARAM_Profile) {
72+ PwProfile profile = PwProfile::parseSpaPod(param);
73+ self->currentProfileUpdated = true;
74+ self->currentProfile = profile;
75 }
76 }
77
78@@ -159,6 +197,21 @@ void PwDevice::polled() {
79 return false;
80 });
81 }
82+ if (this->profilesUpdated) {
83+ this->profiles.removeIf([&](const std::pair<qint32, PwProfile>& entry) {
84+ return !this->validProfiles.contains(entry.first);
85+ });
86+ this->profilesUpdated = false;
87+ QList<PwProfile> profiles = this->profiles.values();
88+ std::sort(profiles.begin(), profiles.end(), [](const PwProfile& a, const PwProfile& b) { return a.index < b.index; });
89+ emit this->profilesChanged(profiles);
90+ }
91+ if (this->currentProfileUpdated) {
92+ this->currentProfileUpdated = false;
93+ if (this->currentProfile) {
94+ emit this->currentProfileChanged(*this->currentProfile);
95+ }
96+ }
97 }
98
99 bool PwDevice::setVolumes(qint32 routeDevice, const QVector<float>& volumes) {
100@@ -196,6 +249,15 @@ bool PwDevice::setMuted(qint32 routeDevice, bool muted) {
101 });
102 }
103
104+void PwDevice::setProfile(qint32 profileIndex) {
105+ auto buffer = std::array<uint8_t, 1024>();
106+ auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size());
107+ auto* pod = spa_pod_builder_add_object(&builder,
108+ SPA_TYPE_OBJECT_ParamProfile, SPA_PARAM_Profile,
109+ SPA_PARAM_PROFILE_index, SPA_POD_Int(profileIndex));
110+ pw_device_set_param(this->proxy(), SPA_PARAM_Profile, 0, static_cast<spa_pod*>(pod));
111+}
112+
113 void PwDevice::waitForDevice() { this->mWaitingForDevice = true; }
114 bool PwDevice::waitingForDevice() const { return this->mWaitingForDevice; }
115
116@@ -236,4 +298,24 @@ bool PwDevice::setRouteProps(
117 return true;
118 }
119
120+PwProfile PwProfile::parseSpaPod(const spa_pod* param) {
121+ PwProfile profile;
122+
123+ const auto* indexProp = spa_pod_find_prop(param, nullptr, SPA_PARAM_PROFILE_index);
124+ const auto* descProp = spa_pod_find_prop(param, nullptr, SPA_PARAM_PROFILE_description);
125+ const auto* nameProp = spa_pod_find_prop(param, nullptr, SPA_PARAM_PROFILE_name);
126+
127+ spa_pod_get_int(&indexProp->value, &profile.index);
128+
129+ const char* desc_cstr = nullptr;
130+ spa_pod_get_string(&descProp->value, &desc_cstr);
131+ profile.description = QString(desc_cstr);
132+
133+ const char* name_cstr = nullptr;
134+ spa_pod_get_string(&nameProp->value, &name_cstr);
135+ profile.name = QString(name_cstr);
136+
137+ return profile;
138+}
139+
140 } // namespace qs::service::pipewire
141diff --git c/src/services/pipewire/device.hpp i/src/services/pipewire/device.hpp
142index f973b18..7cff16f 100644
143--- c/src/services/pipewire/device.hpp
144+++ i/src/services/pipewire/device.hpp
145@@ -1,6 +1,7 @@
146 #pragma once
147
148 #include <functional>
149+#include <optional>
150
151 #include <pipewire/core.h>
152 #include <pipewire/device.h>
153@@ -16,6 +17,20 @@
154
155 namespace qs::service::pipewire {
156
157+struct PwProfile {
158+ Q_GADGET;
159+ Q_PROPERTY(qint32 index MEMBER index)
160+ Q_PROPERTY(QString description MEMBER description)
161+ Q_PROPERTY(QString name MEMBER name)
162+
163+public:
164+ qint32 index;
165+ QString description;
166+ QString name;
167+
168+ static PwProfile parseSpaPod(const spa_pod* param);
169+};
170+
171 class PwDevice;
172
173 // Forward declare to avoid circular dependency with node.hpp
174@@ -27,6 +42,12 @@ class PwDevice: public PwBindable<pw_device, PW_TYPE_INTERFACE_Device, PW_VERSIO
175 public:
176 void bindHooks() override;
177 void unbindHooks() override;
178+ void initProps(const spa_dict* props) override;
179+
180+ QString name;
181+ QString description;
182+ QString nick;
183+ QString type;
184
185 bool setVolumes(qint32 routeDevice, const QVector<float>& volumes);
186 bool setMuted(qint32 routeDevice, bool muted);
187@@ -37,10 +58,17 @@ public:
188 [[nodiscard]] bool tryLoadVolumeProps(qint32 routeDevice, PwVolumeProps& volumeProps);
189 [[nodiscard]] bool hasRouteDevice(qint32 routeDevice) const;
190
191+ void setProfile(qint32 profileIndex);
192+
193+ QHash<qint32, PwProfile> profiles;
194+ std::optional<PwProfile> currentProfile;
195+
196 signals:
197 void deviceReady();
198 void
199 routeVolumesChanged(qint32 routeDevice, const qs::service::pipewire::PwVolumeProps& volumeProps);
200+ void profilesChanged(QList<PwProfile> profiles);
201+ void currentProfileChanged(PwProfile profile);
202
203 private slots:
204 void polled();
205@@ -56,6 +84,11 @@ private:
206 QList<qint32> stagingIndexes;
207 void addDeviceIndexPairs(const spa_pod* param);
208
209+ bool profilesUpdated = false;
210+ QSet<qint32> validProfiles;
211+
212+ bool currentProfileUpdated = false;
213+
214 bool
215 setRouteProps(qint32 routeDevice, const std::function<void*(spa_pod_builder*)>& propsCallback);
216
217diff --git c/src/services/pipewire/node.cpp i/src/services/pipewire/node.cpp
218index 2811833..25e1f8d 100644
219--- c/src/services/pipewire/node.cpp
220+++ i/src/services/pipewire/node.cpp
221@@ -146,6 +146,10 @@ void PwNode::initProps(const spa_dict* props) {
222 this->type = PwNodeType::VideoSink;
223 } else if (strcmp(mediaClass, "Video/Source") == 0) {
224 this->type = PwNodeType::VideoSource;
225+ } else if (strcmp(mediaClass, "Stream/Output/Video") == 0) {
226+ this->type = PwNodeType::VideoOutStream;
227+ } else if (strcmp(mediaClass, "Stream/Input/Video") == 0) {
228+ this->type = PwNodeType::VideoInStream;
229 }
230 }
231
232diff --git c/src/services/pipewire/node.hpp i/src/services/pipewire/node.hpp
233index d96525a..4f44c8d 100644
234--- c/src/services/pipewire/node.hpp
235+++ i/src/services/pipewire/node.hpp
236@@ -145,6 +145,8 @@ public:
237 // This is equivalent to the media class `Video/Sink` and is composed of the
238 // @@PwNodeType.Video and @@PwNodeType.Sink flags.
239 VideoSink = Video | Sink,
240+ VideoOutStream = Video | Sink | Stream,
241+ VideoInStream = Video | Source | Stream,
242 };
243 Q_ENUM(Flag);
244 Q_DECLARE_FLAGS(Flags, Flag);
245diff --git c/src/services/pipewire/qml.cpp i/src/services/pipewire/qml.cpp
246index e4424c1..407096b 100644
247--- c/src/services/pipewire/qml.cpp
248+++ i/src/services/pipewire/qml.cpp
249@@ -8,6 +8,9 @@
250 #include <qtypes.h>
251 #include <qvariant.h>
252
253+#include <cstdint>
254+#include <algorithm>
255+
256 #include "../../core/model.hpp"
257 #include "connection.hpp"
258 #include "defaults.hpp"
259@@ -53,6 +56,12 @@ Pipewire::Pipewire(QObject* parent): QObject(parent) {
260
261 QObject::connect(&connection->registry, &PwRegistry::nodeAdded, this, &Pipewire::onNodeAdded);
262
263+ for (auto* device: connection->registry.devices.values()) {
264+ this->onDeviceAdded(device);
265+ }
266+
267+ QObject::connect(&connection->registry, &PwRegistry::deviceAdded, this, &Pipewire::onDeviceAdded);
268+
269 for (auto* link: connection->registry.links.values()) {
270 this->onLinkAdded(link);
271 }
272@@ -115,6 +124,19 @@ void Pipewire::onNodeRemoved(QObject* object) {
273 this->mNodes.removeObject(iface);
274 }
275
276+ObjectModel<PwDeviceIface>* Pipewire::devices() { return &this->mDevices; }
277+
278+void Pipewire::onDeviceAdded(PwDevice* device) {
279+ auto* iface = PwDeviceIface::instance(device);
280+ QObject::connect(iface, &QObject::destroyed, this, &Pipewire::onDeviceRemoved);
281+ this->mDevices.insertObject(iface);
282+}
283+
284+void Pipewire::onDeviceRemoved(QObject* object) {
285+ auto* iface = static_cast<PwDeviceIface*>(object); // NOLINT
286+ this->mDevices.removeObject(iface);
287+}
288+
289 ObjectModel<PwLinkIface>* Pipewire::links() { return &this->mLinks; }
290
291 void Pipewire::onLinkAdded(PwLink* link) {
292@@ -352,6 +374,8 @@ QVariantMap PwNodeIface::properties() const {
293
294 PwNodeAudioIface* PwNodeIface::audio() const { return this->audioIface; }
295
296+PwDeviceIface* PwNodeIface::device() const { return PwDeviceIface::instance(this->mNode->device); }
297+
298 PwNodeIface* PwNodeIface::instance(PwNode* node) {
299 if (node == nullptr) return nullptr;
300
301@@ -476,4 +500,42 @@ void PwObjectTracker::objectDestroyed(QObject* object) {
302 emit this->objectsChanged();
303 }
304
305+PwDeviceIface::PwDeviceIface(PwDevice* device): PwObjectIface(device), mDevice(device) {
306+ QObject::connect(device, &PwDevice::profilesChanged, this, &PwDeviceIface::deviceProfilesChanged);
307+ QObject::connect(device, &PwDevice::currentProfileChanged, this, &PwDeviceIface::deviceCurrentProfileChanged);
308+}
309+
310+void PwDeviceIface::deviceProfilesChanged(QList<PwProfile>) { emit this->profilesChanged(); }
311+void PwDeviceIface::deviceCurrentProfileChanged(PwProfile) { emit this->currentProfileChanged(); }
312+
313+quint32 PwDeviceIface::id() const { return this->mDevice->id; }
314+QString PwDeviceIface::name() const { return this->mDevice->name; }
315+QString PwDeviceIface::description() const { return this->mDevice->description; }
316+QString PwDeviceIface::nickname() const { return this->mDevice->nick; }
317+QString PwDeviceIface::type() const { return this->mDevice->type; }
318+QList<PwProfile> PwDeviceIface::profiles() const {
319+ QList<PwProfile> profiles = this->mDevice->profiles.values();
320+ std::sort(profiles.begin(), profiles.end(), [](const PwProfile& a, const PwProfile& b) { return a.index < b.index; });
321+ return profiles;
322+}
323+qint32 PwDeviceIface::currentProfile() const { return this->mDevice->currentProfile->index; }
324+
325+PwDeviceIface* PwDeviceIface::instance(PwDevice* device) {
326+ if (device == nullptr) return nullptr;
327+
328+ auto v = device->property("iface");
329+ if (v.canConvert<PwDeviceIface*>()) {
330+ return v.value<PwDeviceIface*>();
331+ }
332+
333+ auto* instance = new PwDeviceIface(device);
334+ device->setProperty("iface", QVariant::fromValue(instance));
335+
336+ return instance;
337+}
338+
339+void PwDeviceIface::setProfile(qint32 profileIndex) {
340+ this->mDevice->setProfile(profileIndex);
341+}
342+
343 } // namespace qs::service::pipewire
344diff --git c/src/services/pipewire/qml.hpp i/src/services/pipewire/qml.hpp
345index 93c4844..87cbcb3 100644
346--- c/src/services/pipewire/qml.hpp
347+++ i/src/services/pipewire/qml.hpp
348@@ -12,11 +12,13 @@
349 #include "../../core/model.hpp"
350 #include "link.hpp"
351 #include "node.hpp"
352+#include "device.hpp"
353 #include "registry.hpp"
354
355 namespace qs::service::pipewire {
356
357 class PwNodeIface;
358+class PwDeviceIface;
359 class PwLinkIface;
360 class PwLinkGroupIface;
361
362@@ -65,6 +67,8 @@ class Pipewire: public QObject {
363 /// - @@PwNode.audio - if non null the node is an audio node.
364 QSDOC_TYPE_OVERRIDE(ObjectModel<qs::service::pipewire::PwNodeIface>*);
365 Q_PROPERTY(UntypedObjectModel* nodes READ nodes CONSTANT);
366+ QSDOC_TYPE_OVERRIDE(ObjectModel<qs::service::pipewire::PwDeviceIface>*);
367+ Q_PROPERTY(UntypedObjectModel* devices READ devices CONSTANT);
368 /// All links present in pipewire.
369 ///
370 /// Links connect pipewire nodes to each other, and can be used to determine
371@@ -134,6 +138,7 @@ public:
372 explicit Pipewire(QObject* parent = nullptr);
373
374 [[nodiscard]] ObjectModel<PwNodeIface>* nodes();
375+ [[nodiscard]] ObjectModel<PwDeviceIface>* devices();
376 [[nodiscard]] ObjectModel<PwLinkIface>* links();
377 [[nodiscard]] ObjectModel<PwLinkGroupIface>* linkGroups();
378
379@@ -159,7 +164,9 @@ signals:
380
381 private slots:
382 void onNodeAdded(qs::service::pipewire::PwNode* node);
383+ void onDeviceAdded(PwDevice* node);
384 void onNodeRemoved(QObject* object);
385+ void onDeviceRemoved(QObject* object);
386 void onLinkAdded(qs::service::pipewire::PwLink* link);
387 void onLinkRemoved(QObject* object);
388 void onLinkGroupAdded(qs::service::pipewire::PwLinkGroup* group);
389@@ -167,6 +174,7 @@ private slots:
390
391 private:
392 ObjectModel<PwNodeIface> mNodes {this};
393+ ObjectModel<PwDeviceIface> mDevices {this};
394 ObjectModel<PwLinkIface> mLinks {this};
395 ObjectModel<PwLinkGroupIface> mLinkGroups {this};
396 };
397@@ -315,6 +323,7 @@ class PwNodeIface: public PwObjectIface {
398 /// > [!NOTE] The node may be used before it is fully bound, but some data
399 /// > may be missing or incorrect.
400 Q_PROPERTY(bool ready READ isReady NOTIFY readyChanged);
401+ Q_PROPERTY(qs::service::pipewire::PwDeviceIface* device READ device CONSTANT);
402 QML_NAMED_ELEMENT(PwNode);
403 QML_UNCREATABLE("PwNodes cannot be created directly");
404
405@@ -332,6 +341,7 @@ public:
406 [[nodiscard]] PwNodeType::Flags type() const;
407 [[nodiscard]] QVariantMap properties() const;
408 [[nodiscard]] PwNodeAudioIface* audio() const;
409+ [[nodiscard]] PwDeviceIface* device() const;
410
411 static PwNodeIface* instance(PwNode* node);
412
413@@ -344,6 +354,44 @@ private:
414 PwNodeAudioIface* audioIface = nullptr;
415 };
416
417+class PwDeviceIface: public PwObjectIface {
418+ Q_OBJECT;
419+ Q_PROPERTY(quint32 id READ id CONSTANT);
420+ Q_PROPERTY(QString name READ name CONSTANT);
421+ Q_PROPERTY(QString description READ description CONSTANT);
422+ Q_PROPERTY(QString nickname READ nickname CONSTANT);
423+ Q_PROPERTY(QString type READ type CONSTANT);
424+ Q_PROPERTY(QList<PwProfile> profiles READ profiles NOTIFY profilesChanged);
425+ Q_PROPERTY(qint32 currentProfile READ currentProfile NOTIFY currentProfileChanged);
426+
427+ QML_NAMED_ELEMENT(PwDevice);
428+ QML_UNCREATABLE("PwDevices cannot be created directly");
429+
430+signals:
431+ void profilesChanged();
432+ void currentProfileChanged();
433+
434+public:
435+ explicit PwDeviceIface(PwDevice* node);
436+
437+ [[nodiscard]] quint32 id() const;
438+ [[nodiscard]] QString name() const;
439+ [[nodiscard]] QString description() const;
440+ [[nodiscard]] QString nickname() const;
441+ [[nodiscard]] QString type() const;
442+ QList<PwProfile> profiles() const;
443+ qint32 currentProfile() const;
444+
445+ Q_INVOKABLE void setProfile(qint32 profileIndex);
446+
447+ static PwDeviceIface* instance(PwDevice* node);
448+private:
449+ PwDevice* mDevice;
450+
451+ void deviceProfilesChanged(QList<PwProfile> profiles);
452+ void deviceCurrentProfileChanged(PwProfile profile);
453+};
454+
455 ///! A connection between pipewire nodes.
456 /// Note that there is one link per *channel* of a connection between nodes.
457 /// You usually want @@PwLinkGroup.
458diff --git c/src/services/pipewire/registry.cpp i/src/services/pipewire/registry.cpp
459index 4b670b1..4295874 100644
460--- c/src/services/pipewire/registry.cpp
461+++ i/src/services/pipewire/registry.cpp
462@@ -236,6 +236,7 @@ void PwRegistry::onGlobal(
463 device->initProps(props);
464
465 self->devices.emplace(id, device);
466+ emit self->deviceAdded(device);
467 }
468 }
469
470@@ -251,6 +252,9 @@ void PwRegistry::onGlobalRemoved(void* data, quint32 id) {
471 } else if (auto* node = self->nodes.value(id)) {
472 self->nodes.remove(id);
473 node->safeDestroy();
474+ } else if (auto* device = self->devices.value(id)) {
475+ self->devices.remove(id);
476+ device->safeDestroy();
477 }
478 }
479
480diff --git c/src/services/pipewire/registry.hpp i/src/services/pipewire/registry.hpp
481index ec0a9ea..c9be65a 100644
482--- c/src/services/pipewire/registry.hpp
483+++ i/src/services/pipewire/registry.hpp
484@@ -133,6 +133,7 @@ public:
485
486 signals:
487 void nodeAdded(qs::service::pipewire::PwNode* node);
488+ void deviceAdded(PwDevice* node);
489 void linkAdded(qs::service::pipewire::PwLink* link);
490 void linkGroupAdded(qs::service::pipewire::PwLinkGroup* group);
491 void metadataAdded(qs::service::pipewire::PwMetadata* metadata);