Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qwasmmediadevices.cpp
Go to the documentation of this file.
1// Copyright (C) 2021 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3
5#include "private/qcameradevice_p.h"
6#include "private/qplatformmediaintegration_p.h"
10#include <QtMultimedia/private/qmultimedia_ranges_p.h>
11
12#include <QDebug>
13
14#include <emscripten.h>
15
16#include <algorithm>
17#include <string_view>
18#include <utility>
19
20QT_BEGIN_NAMESPACE
21
22namespace ranges = QtMultimediaPrivate::ranges;
23
24Q_LOGGING_CATEGORY(qWasmMediaDevices, "qt.multimedia.wasm.mediadevices")
25
26namespace {
27
28// Chrome and Edge add synthetic entries to enumerateDevices() that alias a real
29// device: "default" tracks the system default, "communications" the device picked
30// for voice calls. Both carry the aliased device's groupId. Firefox and Safari
31// have no such entries and simply enumerate the default device first.
32constexpr std::string_view s_defaultDeviceId = "default";
33constexpr std::string_view s_communicationsDeviceId = "communications";
34
35bool isAliasDeviceId(std::string_view deviceId)
36{
37 return deviceId == s_defaultDeviceId || deviceId == s_communicationsDeviceId;
38}
39
40std::string stringProperty(const emscripten::val &mediaDevice, const char *property)
41{
42 const emscripten::val value = mediaDevice[property];
43 if (value.isUndefined() || value.isNull())
44 return std::string();
45 return value.as<std::string>();
46}
47
48// enumerateDevices() reports empty ids until permission is granted, and we
49// substitute a synthetic per-kind id for those. Default lookup must match.
50std::string effectiveDeviceId(const std::string &deviceId, const std::string &deviceKind)
51{
52 return deviceId.empty() ? "System " + deviceKind : deviceId;
53}
54
55template <typename DeviceList>
56bool containsDeviceId(const DeviceList &devices, const QByteArray &deviceId)
57{
58 return ranges::any_of(devices, [&deviceId](const auto &device) {
59 return device.id() == deviceId;
60 });
61}
62
63} // namespace
64
66static bool s_constructingInstance = false;
67
68bool isFirefox() {
69 return !emscripten::val::global("InstallTrigger").isUndefined();
70}
71
72
73// Firefox only as it limits enumerateDevices to inputs only when no permissions are given
74extern "C" {
80} // extern "C"
81
83 const overlay = document.createElement('div');
84 overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;';
85
86 const dialog = document.createElement('div');
87 dialog.style.cssText = 'background:white;padding:24px;border-radius:8px;text-align:center;font-family:sans-serif;min-width:240px;';
88
89 const message = document.createElement('p');
90 message.textContent = 'Select an audio output device to continue.';
91 message.style.cssText = 'margin:0 0 16px 0;font-size:14px;';
92
93 const button = document.createElement('button');
94 button.textContent = 'Select Audio Output';
95 button.style.cssText = 'padding:8px 16px;font-size:14px;cursor:pointer;';
96
97 button.addEventListener('click', async () => {
99 try {
101 console.log("Selected device: ", deviceInfo.label);
103 } catch (err) {
105 }
106 }, { once: true });
107
112});
113
114QWasmCameraDevices::QWasmCameraDevices(QPlatformMediaIntegration *integration)
115 : QPlatformVideoDevices(integration)
116{
117}
118
123
124void QWasmCameraDevices::connectNotify(const QMetaMethod &)
125{
126 Q_ASSERT(QThread::isMainThread());
128}
129
131
133 const QAudioFormat &fmt,
134 QObject *parent)
135{
136 return new QWasmAudioSource(deviceInfo, fmt, parent);
137}
138
139QPlatformAudioSink *QWasmAudioDevices::createAudioSink(const QAudioDevice &deviceInfo,
140 const QAudioFormat &fmt,
141 QObject *parent)
142{
143 return new QWasmAudioSink(deviceInfo, fmt, parent);
144}
145
150
155
156void QWasmAudioDevices::connectNotify(const QMetaMethod &)
157{
158 Q_ASSERT(QThread::isMainThread());
160}
161
165
178
180{
181 if (m_initDone)
182 return;
183
184 m_initDone = true;
185
186 // Seed a synchronous fallback output device immediately: enumerateDevices() below
187 // is asynchronous, so without this, any consumer that needs a device synchronously
188 // right after construction (e.g. QSoundEffect) sees an empty list and fails.
189 insertFallbackAudioOutput();
190
191 if (isFirefox())
192 setupAudioOutputSelector();
193 else
194 getMediaDevices(); // asynchronous
195}
196
198{
199 return m_cameraDevices;
200}
201
203{
204 return m_audioInputs;
205}
206
208{
209 return m_audioOutputs;
210}
211
212// Works out which enumerated device should carry the isDefault flag. When the
213// browser publishes a synthetic "default" entry it is resolved back to the real
214// device through its groupId, so the returned id is one that also ends up in our
215// device lists. Otherwise the first enumerated device of that kind wins.
216QByteArray QWasmMediaDevices::findDefaultDeviceId(emscripten::val devices,
217 std::string_view deviceKind) const
218{
219 const QString kindName = QString::fromUtf8(deviceKind.data(), qsizetype(deviceKind.size()));
220 std::string defaultGroupId;
221 QList<std::pair<QByteArray, std::string>> candidates;
222
223 const int deviceCount = devices["length"].as<int>();
224 for (int i = 0; i < deviceCount; ++i) {
225 const emscripten::val mediaDevice = devices[i];
226 const std::string kind = stringProperty(mediaDevice, "kind");
227 if (kind != deviceKind)
228 continue;
229
230 const std::string deviceId = stringProperty(mediaDevice, "deviceId");
231 if (deviceId == s_defaultDeviceId) {
232 defaultGroupId = stringProperty(mediaDevice, "groupId");
233 continue;
234 }
235 if (isAliasDeviceId(deviceId))
236 continue;
237
238 candidates.append({ QByteArray::fromStdString(effectiveDeviceId(deviceId, kind)),
239 stringProperty(mediaDevice, "groupId") });
240 }
241
242 if (candidates.isEmpty()) {
243 qCDebug(qWasmMediaDevices) << "no" << kindName << "devices enumerated, no default";
244 return {};
245 }
246
247 if (!defaultGroupId.empty()) {
248 const auto it = ranges::find_if(candidates, [&defaultGroupId](const auto &candidate) {
249 return candidate.second == defaultGroupId;
250 });
251 if (it != candidates.cend()) {
252 qCDebug(qWasmMediaDevices)
253 << "default" << kindName << "resolved from the browser's default entry:"
254 << it->first << "via groupId" << QString::fromStdString(defaultGroupId);
255 return it->first;
256 }
257 qCDebug(qWasmMediaDevices)
258 << "the browser's default" << kindName << "entry has groupId"
259 << QString::fromStdString(defaultGroupId)
260 << "which matches no enumerated device, falling back to the first";
261 }
262 qCDebug(qWasmMediaDevices) << "default" << kindName
263 << "falls back to the first enumerated device:"
264 << candidates.constFirst().first;
265 return candidates.constFirst().first;
266}
267
268void QWasmMediaDevices::applyDefaultDevice(QList<QAudioDevice> &audioDevices,
269 const QByteArray &defaultDeviceId)
270{
271 for (QAudioDevice &audioDevice : audioDevices) {
272 QAudioDevicePrivate *devicePrivate = QAudioDevicePrivate::handle(audioDevice);
273 if (!devicePrivate)
274 continue;
275 const bool isDefault = audioDevice.id() == defaultDeviceId;
276 if (devicePrivate->isDefault == isDefault)
277 continue;
278 qCDebug(qWasmMediaDevices) << "default" << audioDevice.mode()
279 << (isDefault ? "set to" : "cleared from")
280 << audioDevice.id() << audioDevice.description();
281 devicePrivate->isDefault = isDefault;
282 }
283}
284
285void QWasmMediaDevices::applyDefaultDevice(QList<QCameraDevice> &cameraDevices,
286 const QByteArray &defaultDeviceId)
287{
288 for (QCameraDevice &cameraDevice : cameraDevices) {
289 QCameraDevicePrivate *devicePrivate = QCameraDevicePrivate::handle(cameraDevice);
290 if (!devicePrivate)
291 continue;
292 const bool isDefault = cameraDevice.id() == defaultDeviceId;
293 if (devicePrivate->isDefault == isDefault)
294 continue;
295 qCDebug(qWasmMediaDevices) << "default camera"
296 << (isDefault ? "set to" : "cleared from")
297 << cameraDevice.id() << cameraDevice.description();
298 devicePrivate->isDefault = isDefault;
299 }
300}
301
302void QWasmMediaDevices::parseDevices(emscripten::val devices)
303{
304 if (devices.isNull() || devices.isUndefined()) {
305 qWarning() << "Something went wrong enumerating devices";
306 return;
307 }
308
309 QList<QByteArray> cameraDevicesToRemove;
310 cameraDevicesToRemove.reserve(m_cameraDevices.count());
311 for (const QCameraDevice &cameraDevice : m_cameraDevices)
312 cameraDevicesToRemove.append(cameraDevice.id());
313
314 QList<QByteArray> audioOutputsToRemove;
315 audioOutputsToRemove.reserve(m_audioOutputs.count());
316 for (const QAudioDevice &audioDevice : m_audioOutputs)
317 audioOutputsToRemove.append(audioDevice.id());
318
319 QList<QByteArray> audioInputsToRemove;
320 audioInputsToRemove.reserve(m_audioInputs.count());
321 for (const QAudioDevice &audioDevice : m_audioInputs)
322 audioInputsToRemove.append(audioDevice.id());
323 m_audioInputsAdded = false;
324 m_audioOutputsAdded = false;
325 m_videoInputsAdded = false;
326
327 const QByteArray defaultCameraId = findDefaultDeviceId(devices, "videoinput");
328 const QByteArray defaultAudioInputId = findDefaultDeviceId(devices, "audioinput");
329 const QByteArray defaultAudioOutputId = findDefaultDeviceId(devices, "audiooutput");
330
331 bool m_videoInputsRemoved = false;
332 bool m_audioInputsRemoved = false;
333 bool m_audioOutputsRemoved = false;
334
335 for (int i = 0; i < devices["length"].as<int>(); i++) {
336
337 emscripten::val mediaDevice = devices[i];
338
339 const std::string deviceKind = mediaDevice["kind"].as<std::string>();
340 std::string label = mediaDevice["label"].as<std::string>();
341 std::string deviceId = mediaDevice["deviceId"].as<std::string>();
342
343 if (deviceKind.empty())
344 continue;
345
346 qCDebug(qWasmMediaDevices) << QString::fromStdString(deviceKind)
347 << QString::fromStdString(deviceId)
348 << QString::fromStdString(label) << "groupId"
349 << QString::fromStdString(stringProperty(mediaDevice, "groupId"));
350
351 // the real device this aliases is enumerated separately
352 if (isAliasDeviceId(deviceId))
353 continue;
354
355 if (deviceId.empty()) { // no permissions we'll use System;
356 label = "System " + deviceKind;
357 deviceId = label;
358 }
359
360 const QByteArray qtDeviceId = QByteArray::fromStdString(deviceId);
361
362 if (deviceKind == std::string_view("videoinput")) {
363 if (!containsDeviceId(m_cameraDevices, qtDeviceId)) {
364 QCameraDevicePrivate *camera = new QCameraDevicePrivate; // QSharedData
365 camera->id = qtDeviceId;
366 camera->description = QString::fromUtf8(label.c_str());
367 camera->isDefault = false; // assigned by applyDefaultDevice() below
368 m_cameraDevices.append(camera->create());
369 m_videoInputsAdded = true;
370 }
371 cameraDevicesToRemove.removeOne(qtDeviceId);
372 } else if (deviceKind == std::string_view("audioinput")) {
373 if (!containsDeviceId(m_audioInputs, qtDeviceId)) {
374 m_audioInputs.append(
375 QAudioDevicePrivate::createQAudioDevice(std::make_unique<QWasmAudioDevice>(
376 deviceId.c_str(), label.c_str(), false, QAudioDevice::Input)));
377
378 m_audioInputsAdded = true;
379 }
380 audioInputsToRemove.removeOne(qtDeviceId);
381 } else if (deviceKind == std::string_view("audiooutput")) {
382 if (!containsDeviceId(m_audioOutputs, qtDeviceId)) {
383 m_audioOutputs.append(
384 QAudioDevicePrivate::createQAudioDevice(std::make_unique<QWasmAudioDevice>(
385 deviceId.c_str(), label.c_str(), false, QAudioDevice::Output)));
386
387 m_audioOutputsAdded = true;
388 }
389 audioOutputsToRemove.removeOne(qtDeviceId);
390 }
391 // if permissions are given label will hold the actual
392 // camera name, such as "Live! Cam Sync 1080p (041e:409d)"
393 }
394 // any left here were removed
395 for (const QByteArray &cameraId : cameraDevicesToRemove) {
396 m_cameraDevices.removeIf([&](const QCameraDevice &cameraDevice) {
397 return cameraDevice.id() == cameraId;
398 });
399 }
400 m_videoInputsRemoved = !cameraDevicesToRemove.isEmpty();
401
402 for (const QByteArray &audioDeviceId : audioInputsToRemove) {
403 m_audioInputs.removeIf([&audioDeviceId](const QAudioDevice &audioDevice) {
404 return audioDevice.id() == audioDeviceId;
405 });
406 }
407 m_audioInputsRemoved = !audioInputsToRemove.isEmpty();
408
409 for (const QByteArray &audioDeviceId : audioOutputsToRemove) {
410 m_audioOutputs.removeIf([&audioDeviceId](const QAudioDevice &audioDevice) {
411 return audioDevice.id() == audioDeviceId;
412 });
413 }
414 m_audioOutputsRemoved = !audioOutputsToRemove.isEmpty();
415
416 if (!m_audioOutputsAdded) {
417 // Firefox and Safari require mic or camera permissions
418 // (or selectAudioOutput for Firefox)
419 // to enumerate output devices, so we just fake one.
420 // The device actually does not require perms to play.
421 insertFallbackAudioOutput();
422 }
423
424 // re-evaluated on every enumeration: the default can move when devices are
425 // plugged in or removed, or when the user changes the system default
426 applyDefaultDevice(m_cameraDevices, defaultCameraId);
427 applyDefaultDevice(m_audioInputs, defaultAudioInputId);
428 applyDefaultDevice(m_audioOutputs, defaultAudioOutputId);
429
430 if (m_videoInputsAdded || m_videoInputsRemoved) {
431 auto videoDevices = static_cast<QWasmCameraDevices*>(QPlatformMediaIntegration::instance()->videoDevices());
432 QMetaObject::invokeMethod(videoDevices, &QWasmCameraDevices::onVideoInputsChanged,
433 Qt::QueuedConnection);
434 }
435 if (m_audioInputsAdded || m_audioInputsRemoved) {
436 auto audioDevices = static_cast<QWasmAudioDevices*>(QPlatformMediaIntegration::instance()->audioDevices());
437 QMetaObject::invokeMethod(audioDevices, &QWasmAudioDevices::onAudioInputsChanged,
438 Qt::QueuedConnection);
439 }
440 if (m_audioOutputsAdded || m_audioOutputsRemoved) {
441 auto audioDevices = static_cast<QWasmAudioDevices*>(QPlatformMediaIntegration::instance()->audioDevices());
442 QMetaObject::invokeMethod(audioDevices, &QWasmAudioDevices::onAudioOutputsChanged,
443 Qt::QueuedConnection);
444 }
445
446}
447
448void QWasmMediaDevices::insertFallbackAudioOutput()
449{
450 const bool hasFallback = std::any_of(m_audioOutputs.cbegin(), m_audioOutputs.cend(),
451 [](const QAudioDevice &audioDevice) {
452 return audioDevice.id().isEmpty();
453 });
454 if (!hasFallback) {
455 m_audioOutputs.append(
456 QAudioDevicePrivate::createQAudioDevice(std::make_unique<QWasmAudioDevice>(
457 "", "System output", true, QAudioDevice::Output)));
458 }
459 m_audioOutputsAdded = true;
460}
461
463{
464 emscripten::val navigator = emscripten::val::global("navigator");
465 m_jsMediaDevicesInterface = navigator["mediaDevices"];
466
467 if (m_jsMediaDevicesInterface.isNull() || m_jsMediaDevicesInterface.isUndefined()) {
468 qWarning() << "No media devices found";
469 return;
470 }
471
472 if (qstdweb::haveAsyncify()) {
473
474#ifdef QT_HAVE_EMSCRIPTEN_ASYNCIFY
475 auto asyncEnumerate = [](void *arg){
476 QWasmMediaDevices *mediaDevices = static_cast<QWasmMediaDevices *>(arg);
477 mediaDevices->devicesList = mediaDevices->m_jsMediaDevicesInterface.call<emscripten::val>("enumerateDevices").await();
478 if (mediaDevices->devicesList.isNull() || mediaDevices->devicesList.isUndefined()) {
479 qWarning() << "devices list error";
480 return;
481 }
482 mediaDevices->parseDevices(mediaDevices->devicesList);
483 };
484
485 asyncEnumerate(this);
486
487 m_deviceChangedCallback = std::make_unique<qstdweb::EventCallback>(
488 m_jsMediaDevicesInterface, "devicechange",
489 [this, asyncEnumerate](emscripten::val) {
490 asyncEnumerate(this);
491 });
492#endif
493
494 } else {
495
496 qstdweb::PromiseCallbacks enumerateDevicesCallback{
497 .thenFunc =
498 [&](emscripten::val devices) {
499 parseDevices(devices);
500 },
501 .catchFunc =
502 [this](emscripten::val error) {
503 qWarning() << "mediadevices enumerateDevices fail"
504 << QString::fromStdString(error["name"].as<std::string>())
505 << QString::fromStdString(error["message"].as<std::string>());
506 m_initDone = false;
507 },
508 .finallyFunc = {}
509 };
510
511 qstdweb::Promise::make(m_jsMediaDevicesInterface,
512 QStringLiteral("enumerateDevices"),
513 std::move(enumerateDevicesCallback));
514
515 // setup devicechange monitor
516 m_deviceChangedCallback = std::make_unique<qstdweb::EventCallback>(
517 m_jsMediaDevicesInterface, "devicechange",
518 [this, enumerateDevicesCallback](emscripten::val) {
519 qstdweb::Promise::make(m_jsMediaDevicesInterface,
520 QStringLiteral("enumerateDevices"),
521 std::move(enumerateDevicesCallback));
522 });
523 }
524
525}
526
527QT_END_NAMESPACE
The QAudioDevice class provides an information about audio devices and their functionality.
The QCameraDevice class provides general information about camera devices.
QPlatformAudioSink * createAudioSink(const QAudioDevice &, const QAudioFormat &, QObject *parent) override
QList< QAudioDevice > findAudioOutputs() const override
QList< QAudioDevice > findAudioInputs() const override
QPlatformAudioSource * createAudioSource(const QAudioDevice &, const QAudioFormat &, QObject *parent) override
void connectNotify(const QMetaMethod &signal) override
void connectNotify(const QMetaMethod &signal) override
QList< QCameraDevice > findVideoInputs() const override
QList< QCameraDevice > videoInputs() const
static QWasmMediaDevices * instance()
QList< QAudioDevice > audioOutputs() const
QList< QAudioDevice > audioInputs() const
std::string effectiveDeviceId(const std::string &deviceId, const std::string &deviceKind)
bool containsDeviceId(const DeviceList &devices, const QByteArray &deviceId)
constexpr std::string_view s_communicationsDeviceId
constexpr std::string_view s_defaultDeviceId
std::string stringProperty(const emscripten::val &mediaDevice, const char *property)
bool isAliasDeviceId(std::string_view deviceId)
Q_LOGGING_CATEGORY(lcEventDispatcher, "qt.eventdispatcher")
static QWasmMediaDevices * s_mediaDevicesInstance
EM_JS(void, setupAudioOutputSelector,(), { const overlay=document.createElement('div');overlay.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0, 0, 0, 0.5);z-index:9999;display:flex;align-items:center;justify-content:center;';const dialog=document.createElement('div');dialog.style.cssText='background:white;padding:24px;border-radius:8px;text-align:center;font-family:sans-serif;min-width:240px;';const message=document.createElement('p');message.textContent='Select an audio output device to continue.';message.style.cssText='margin:0 0 16px 0;font-size:14px;';const button=document.createElement('button');button.textContent='Select Audio Output';button.style.cssText='padding:8px 16px;font-size:14px;cursor:pointer;';button.addEventListener('click', async()=> { document.body.removeChild(overlay);try { const deviceInfo=await navigator.mediaDevices.selectAudioOutput();console.log("Selected device: ", deviceInfo.label);Module._qtMediaDevicesOnAudioOutputSelected();} catch(err) { console.error(err);} }, { once:true });dialog.appendChild(message);dialog.appendChild(button);overlay.appendChild(dialog);document.body.appendChild(overlay);})
static bool s_constructingInstance
EMSCRIPTEN_KEEPALIVE void qtMediaDevicesOnAudioOutputSelected()
bool isFirefox()