Revision control

Copy as Markdown

// Copyright (C) 2021 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
#include "qwasmmediadevices_p.h"
#include "private/qcameradevice_p.h"
#include "private/qplatformmediaintegration_p.h"
#include "qwasmwebaudiosource_p.h"
#include "qwasmwebaudiosink_p.h"
#include "qwasmaudiodevice_p.h"
#include <QtMultimedia/private/qmultimedia_ranges_p.h>
#include <QDebug>
#include <emscripten.h>
#include <algorithm>
#include <string_view>
#include <utility>
QT_BEGIN_NAMESPACE
namespace ranges = QtMultimediaPrivate::ranges;
Q_LOGGING_CATEGORY(qWasmMediaDevices, "qt.multimedia.wasm.mediadevices")
namespace {
// Chrome and Edge add synthetic entries to enumerateDevices() that alias a real
// device: "default" tracks the system default, "communications" the device picked
// for voice calls. Both carry the aliased device's groupId. Firefox and Safari
// have no such entries and simply enumerate the default device first.
constexpr std::string_view s_defaultDeviceId = "default";
constexpr std::string_view s_communicationsDeviceId = "communications";
bool isAliasDeviceId(std::string_view deviceId)
{
return deviceId == s_defaultDeviceId || deviceId == s_communicationsDeviceId;
}
std::string stringProperty(const emscripten::val &mediaDevice, const char *property)
{
const emscripten::val value = mediaDevice[property];
if (value.isUndefined() || value.isNull())
return std::string();
return value.as<std::string>();
}
// enumerateDevices() reports empty ids until permission is granted, and we
// substitute a synthetic per-kind id for those. Default lookup must match.
std::string effectiveDeviceId(const std::string &deviceId, const std::string &deviceKind)
{
return deviceId.empty() ? "System " + deviceKind : deviceId;
}
template <typename DeviceList>
bool containsDeviceId(const DeviceList &devices, const QByteArray &deviceId)
{
return ranges::any_of(devices, [&deviceId](const auto &device) {
return device.id() == deviceId;
});
}
} // namespace
static QWasmMediaDevices *s_mediaDevicesInstance = nullptr;
static bool s_constructingInstance = false;
bool isFirefox() {
return !emscripten::val::global("InstallTrigger").isUndefined();
}
// Firefox only as it limits enumerateDevices to inputs only when no permissions are given
extern "C" {
EMSCRIPTEN_KEEPALIVE void qtMediaDevicesOnAudioOutputSelected()
{
if (QWasmMediaDevices *instance = QWasmMediaDevices::instance())
instance->getMediaDevices();
}
} // extern "C"
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);
});
QWasmCameraDevices::QWasmCameraDevices(QPlatformMediaIntegration *integration)
: QPlatformVideoDevices(integration)
{
}
QList<QCameraDevice> QWasmCameraDevices::findVideoInputs() const
{
return QWasmMediaDevices::instance() ? QWasmMediaDevices::instance()->videoInputs() : QList<QCameraDevice>();
}
void QWasmCameraDevices::connectNotify(const QMetaMethod &)
{
Q_ASSERT(QThread::isMainThread());
QWasmMediaDevices::instance();
}
QWasmAudioDevices::QWasmAudioDevices() = default;
QPlatformAudioSource *QWasmAudioDevices::createAudioSource(const QAudioDevice &deviceInfo,
const QAudioFormat &fmt,
QObject *parent)
{
return new QWasmAudioSource(deviceInfo, fmt, parent);
}
QPlatformAudioSink *QWasmAudioDevices::createAudioSink(const QAudioDevice &deviceInfo,
const QAudioFormat &fmt,
QObject *parent)
{
return new QWasmAudioSink(deviceInfo, fmt, parent);
}
QList<QAudioDevice> QWasmAudioDevices::findAudioInputs() const
{
return QWasmMediaDevices::instance() ? QWasmMediaDevices::instance()->audioInputs() : QList<QAudioDevice>();
}
QList<QAudioDevice> QWasmAudioDevices::findAudioOutputs() const
{
return QWasmMediaDevices::instance() ? QWasmMediaDevices::instance()->audioOutputs() : QList<QAudioDevice>();
}
void QWasmAudioDevices::connectNotify(const QMetaMethod &)
{
Q_ASSERT(QThread::isMainThread());
QWasmMediaDevices::instance();
}
QWasmMediaDevices::QWasmMediaDevices()
{
}
QWasmMediaDevices *QWasmMediaDevices::instance()
{
if (s_mediaDevicesInstance)
return s_mediaDevicesInstance;
if (s_constructingInstance)
return nullptr;
s_constructingInstance = true;
s_mediaDevicesInstance = new QWasmMediaDevices();
s_constructingInstance = false;
s_mediaDevicesInstance->initDevices();
return s_mediaDevicesInstance;
}
void QWasmMediaDevices::initDevices()
{
if (m_initDone)
return;
m_initDone = true;
// Seed a synchronous fallback output device immediately: enumerateDevices() below
// is asynchronous, so without this, any consumer that needs a device synchronously
// right after construction (e.g. QSoundEffect) sees an empty list and fails.
insertFallbackAudioOutput();
if (isFirefox())
setupAudioOutputSelector();
else
getMediaDevices(); // asynchronous
}
QList<QCameraDevice> QWasmMediaDevices::videoInputs() const
{
return m_cameraDevices;
}
QList<QAudioDevice> QWasmMediaDevices::audioInputs() const
{
return m_audioInputs;
}
QList<QAudioDevice> QWasmMediaDevices::audioOutputs() const
{
return m_audioOutputs;
}
// Works out which enumerated device should carry the isDefault flag. When the
// browser publishes a synthetic "default" entry it is resolved back to the real
// device through its groupId, so the returned id is one that also ends up in our
// device lists. Otherwise the first enumerated device of that kind wins.
QByteArray QWasmMediaDevices::findDefaultDeviceId(emscripten::val devices,
std::string_view deviceKind) const
{
const QString kindName = QString::fromUtf8(deviceKind.data(), qsizetype(deviceKind.size()));
std::string defaultGroupId;
QList<std::pair<QByteArray, std::string>> candidates;
const int deviceCount = devices["length"].as<int>();
for (int i = 0; i < deviceCount; ++i) {
const emscripten::val mediaDevice = devices[i];
const std::string kind = stringProperty(mediaDevice, "kind");
if (kind != deviceKind)
continue;
const std::string deviceId = stringProperty(mediaDevice, "deviceId");
if (deviceId == s_defaultDeviceId) {
defaultGroupId = stringProperty(mediaDevice, "groupId");
continue;
}
if (isAliasDeviceId(deviceId))
continue;
candidates.append({ QByteArray::fromStdString(effectiveDeviceId(deviceId, kind)),
stringProperty(mediaDevice, "groupId") });
}
if (candidates.isEmpty()) {
qCDebug(qWasmMediaDevices) << "no" << kindName << "devices enumerated, no default";
return {};
}
if (!defaultGroupId.empty()) {
const auto it = ranges::find_if(candidates, [&defaultGroupId](const auto &candidate) {
return candidate.second == defaultGroupId;
});
if (it != candidates.cend()) {
qCDebug(qWasmMediaDevices)
<< "default" << kindName << "resolved from the browser's default entry:"
<< it->first << "via groupId" << QString::fromStdString(defaultGroupId);
return it->first;
}
qCDebug(qWasmMediaDevices)
<< "the browser's default" << kindName << "entry has groupId"
<< QString::fromStdString(defaultGroupId)
<< "which matches no enumerated device, falling back to the first";
}
qCDebug(qWasmMediaDevices) << "default" << kindName
<< "falls back to the first enumerated device:"
<< candidates.constFirst().first;
return candidates.constFirst().first;
}
void QWasmMediaDevices::applyDefaultDevice(QList<QAudioDevice> &audioDevices,
const QByteArray &defaultDeviceId)
{
for (QAudioDevice &audioDevice : audioDevices) {
QAudioDevicePrivate *devicePrivate = QAudioDevicePrivate::handle(audioDevice);
if (!devicePrivate)
continue;
const bool isDefault = audioDevice.id() == defaultDeviceId;
if (devicePrivate->isDefault == isDefault)
continue;
qCDebug(qWasmMediaDevices) << "default" << audioDevice.mode()
<< (isDefault ? "set to" : "cleared from")
<< audioDevice.id() << audioDevice.description();
devicePrivate->isDefault = isDefault;
}
}
void QWasmMediaDevices::applyDefaultDevice(QList<QCameraDevice> &cameraDevices,
const QByteArray &defaultDeviceId)
{
for (QCameraDevice &cameraDevice : cameraDevices) {
QCameraDevicePrivate *devicePrivate = QCameraDevicePrivate::handle(cameraDevice);
if (!devicePrivate)
continue;
const bool isDefault = cameraDevice.id() == defaultDeviceId;
if (devicePrivate->isDefault == isDefault)
continue;
qCDebug(qWasmMediaDevices) << "default camera"
<< (isDefault ? "set to" : "cleared from")
<< cameraDevice.id() << cameraDevice.description();
devicePrivate->isDefault = isDefault;
}
}
void QWasmMediaDevices::parseDevices(emscripten::val devices)
{
if (devices.isNull() || devices.isUndefined()) {
qWarning() << "Something went wrong enumerating devices";
return;
}
QList<QByteArray> cameraDevicesToRemove;
cameraDevicesToRemove.reserve(m_cameraDevices.count());
for (const QCameraDevice &cameraDevice : m_cameraDevices)
cameraDevicesToRemove.append(cameraDevice.id());
QList<QByteArray> audioOutputsToRemove;
audioOutputsToRemove.reserve(m_audioOutputs.count());
for (const QAudioDevice &audioDevice : m_audioOutputs)
audioOutputsToRemove.append(audioDevice.id());
QList<QByteArray> audioInputsToRemove;
audioInputsToRemove.reserve(m_audioInputs.count());
for (const QAudioDevice &audioDevice : m_audioInputs)
audioInputsToRemove.append(audioDevice.id());
m_audioInputsAdded = false;
m_audioOutputsAdded = false;
m_videoInputsAdded = false;
const QByteArray defaultCameraId = findDefaultDeviceId(devices, "videoinput");
const QByteArray defaultAudioInputId = findDefaultDeviceId(devices, "audioinput");
const QByteArray defaultAudioOutputId = findDefaultDeviceId(devices, "audiooutput");
bool m_videoInputsRemoved = false;
bool m_audioInputsRemoved = false;
bool m_audioOutputsRemoved = false;
for (int i = 0; i < devices["length"].as<int>(); i++) {
emscripten::val mediaDevice = devices[i];
const std::string deviceKind = mediaDevice["kind"].as<std::string>();
std::string label = mediaDevice["label"].as<std::string>();
std::string deviceId = mediaDevice["deviceId"].as<std::string>();
if (deviceKind.empty())
continue;
qCDebug(qWasmMediaDevices) << QString::fromStdString(deviceKind)
<< QString::fromStdString(deviceId)
<< QString::fromStdString(label) << "groupId"
<< QString::fromStdString(stringProperty(mediaDevice, "groupId"));
// the real device this aliases is enumerated separately
if (isAliasDeviceId(deviceId))
continue;
if (deviceId.empty()) { // no permissions we'll use System;
label = "System " + deviceKind;
deviceId = label;
}
const QByteArray qtDeviceId = QByteArray::fromStdString(deviceId);
if (deviceKind == std::string_view("videoinput")) {
if (!containsDeviceId(m_cameraDevices, qtDeviceId)) {
QCameraDevicePrivate *camera = new QCameraDevicePrivate; // QSharedData
camera->id = qtDeviceId;
camera->description = QString::fromUtf8(label.c_str());
camera->isDefault = false; // assigned by applyDefaultDevice() below
m_cameraDevices.append(camera->create());
m_videoInputsAdded = true;
}
cameraDevicesToRemove.removeOne(qtDeviceId);
} else if (deviceKind == std::string_view("audioinput")) {
if (!containsDeviceId(m_audioInputs, qtDeviceId)) {
m_audioInputs.append(
QAudioDevicePrivate::createQAudioDevice(std::make_unique<QWasmAudioDevice>(
deviceId.c_str(), label.c_str(), false, QAudioDevice::Input)));
m_audioInputsAdded = true;
}
audioInputsToRemove.removeOne(qtDeviceId);
} else if (deviceKind == std::string_view("audiooutput")) {
if (!containsDeviceId(m_audioOutputs, qtDeviceId)) {
m_audioOutputs.append(
QAudioDevicePrivate::createQAudioDevice(std::make_unique<QWasmAudioDevice>(
deviceId.c_str(), label.c_str(), false, QAudioDevice::Output)));
m_audioOutputsAdded = true;
}
audioOutputsToRemove.removeOne(qtDeviceId);
}
// if permissions are given label will hold the actual
// camera name, such as "Live! Cam Sync 1080p (041e:409d)"
}
// any left here were removed
for (const QByteArray &cameraId : cameraDevicesToRemove) {
m_cameraDevices.removeIf([&](const QCameraDevice &cameraDevice) {
return cameraDevice.id() == cameraId;
});
}
m_videoInputsRemoved = !cameraDevicesToRemove.isEmpty();
for (const QByteArray &audioDeviceId : audioInputsToRemove) {
m_audioInputs.removeIf([&audioDeviceId](const QAudioDevice &audioDevice) {
return audioDevice.id() == audioDeviceId;
});
}
m_audioInputsRemoved = !audioInputsToRemove.isEmpty();
for (const QByteArray &audioDeviceId : audioOutputsToRemove) {
m_audioOutputs.removeIf([&audioDeviceId](const QAudioDevice &audioDevice) {
return audioDevice.id() == audioDeviceId;
});
}
m_audioOutputsRemoved = !audioOutputsToRemove.isEmpty();
if (!m_audioOutputsAdded) {
// Firefox and Safari require mic or camera permissions
// (or selectAudioOutput for Firefox)
// to enumerate output devices, so we just fake one.
// The device actually does not require perms to play.
insertFallbackAudioOutput();
}
// re-evaluated on every enumeration: the default can move when devices are
// plugged in or removed, or when the user changes the system default
applyDefaultDevice(m_cameraDevices, defaultCameraId);
applyDefaultDevice(m_audioInputs, defaultAudioInputId);
applyDefaultDevice(m_audioOutputs, defaultAudioOutputId);
if (m_videoInputsAdded || m_videoInputsRemoved) {
auto videoDevices = static_cast<QWasmCameraDevices*>(QPlatformMediaIntegration::instance()->videoDevices());
QMetaObject::invokeMethod(videoDevices, &QWasmCameraDevices::onVideoInputsChanged,
Qt::QueuedConnection);
}
if (m_audioInputsAdded || m_audioInputsRemoved) {
auto audioDevices = static_cast<QWasmAudioDevices*>(QPlatformMediaIntegration::instance()->audioDevices());
QMetaObject::invokeMethod(audioDevices, &QWasmAudioDevices::onAudioInputsChanged,
Qt::QueuedConnection);
}
if (m_audioOutputsAdded || m_audioOutputsRemoved) {
auto audioDevices = static_cast<QWasmAudioDevices*>(QPlatformMediaIntegration::instance()->audioDevices());
QMetaObject::invokeMethod(audioDevices, &QWasmAudioDevices::onAudioOutputsChanged,
Qt::QueuedConnection);
}
}
void QWasmMediaDevices::insertFallbackAudioOutput()
{
const bool hasFallback = std::any_of(m_audioOutputs.cbegin(), m_audioOutputs.cend(),
[](const QAudioDevice &audioDevice) {
return audioDevice.id().isEmpty();
});
if (!hasFallback) {
m_audioOutputs.append(
QAudioDevicePrivate::createQAudioDevice(std::make_unique<QWasmAudioDevice>(
"", "System output", true, QAudioDevice::Output)));
}
m_audioOutputsAdded = true;
}
void QWasmMediaDevices::getMediaDevices()
{
emscripten::val navigator = emscripten::val::global("navigator");
m_jsMediaDevicesInterface = navigator["mediaDevices"];
if (m_jsMediaDevicesInterface.isNull() || m_jsMediaDevicesInterface.isUndefined()) {
qWarning() << "No media devices found";
return;
}
if (qstdweb::haveAsyncify()) {
#ifdef QT_HAVE_EMSCRIPTEN_ASYNCIFY
auto asyncEnumerate = [](void *arg){
QWasmMediaDevices *mediaDevices = static_cast<QWasmMediaDevices *>(arg);
mediaDevices->devicesList = mediaDevices->m_jsMediaDevicesInterface.call<emscripten::val>("enumerateDevices").await();
if (mediaDevices->devicesList.isNull() || mediaDevices->devicesList.isUndefined()) {
qWarning() << "devices list error";
return;
}
mediaDevices->parseDevices(mediaDevices->devicesList);
};
asyncEnumerate(this);
m_deviceChangedCallback = std::make_unique<qstdweb::EventCallback>(
m_jsMediaDevicesInterface, "devicechange",
[this, asyncEnumerate](emscripten::val) {
asyncEnumerate(this);
});
#endif
} else {
qstdweb::PromiseCallbacks enumerateDevicesCallback{
.thenFunc =
[&](emscripten::val devices) {
parseDevices(devices);
},
.catchFunc =
[this](emscripten::val error) {
qWarning() << "mediadevices enumerateDevices fail"
<< QString::fromStdString(error["name"].as<std::string>())
<< QString::fromStdString(error["message"].as<std::string>());
m_initDone = false;
},
.finallyFunc = {}
};
qstdweb::Promise::make(m_jsMediaDevicesInterface,
QStringLiteral("enumerateDevices"),
std::move(enumerateDevicesCallback));
// setup devicechange monitor
m_deviceChangedCallback = std::make_unique<qstdweb::EventCallback>(
m_jsMediaDevicesInterface, "devicechange",
[this, enumerateDevicesCallback](emscripten::val) {
qstdweb::Promise::make(m_jsMediaDevicesInterface,
QStringLiteral("enumerateDevices"),
std::move(enumerateDevicesCallback));
});
}
}
QT_END_NAMESPACE