File indexing completed on 2024-04-28 16:51:33

0001 /*
0002     SPDX-FileCopyrightText: 2017 Kai Uwe Broulik <kde@privat.broulik.de>
0003     SPDX-FileCopyrightText: 2017 David Edmundson <davidedmundson@kde.org>
0004 
0005     SPDX-License-Identifier: MIT
0006 */
0007 
0008 #include "mprisplugin.h"
0009 
0010 #include <QCoreApplication>
0011 #include <QDBusConnection>
0012 #include <QDBusObjectPath>
0013 #include <QGuiApplication>
0014 #include <QImageReader>
0015 
0016 #include "mprisplayer.h"
0017 #include "mprisroot.h"
0018 
0019 #include <unistd.h> // getppid
0020 
0021 static const QString s_serviceName = QStringLiteral("org.mpris.MediaPlayer2.plasma-browser-integration");
0022 
0023 MPrisPlugin::MPrisPlugin(QObject *parent)
0024     : AbstractBrowserPlugin(QStringLiteral("mpris"), 1, parent)
0025     , m_root(new MPrisRoot(this))
0026     , m_player(new MPrisPlayer(this))
0027     , m_playbackStatus(QStringLiteral("Stopped"))
0028     , m_loopStatus(QStringLiteral("None"))
0029 {
0030     if (!QDBusConnection::sessionBus().registerObject(QStringLiteral("/org/mpris/MediaPlayer2"), this)) {
0031         qWarning() << "Failed to register MPris object";
0032         return;
0033     }
0034 
0035     m_propertyChangeSignalTimer.setInterval(0);
0036     m_propertyChangeSignalTimer.setSingleShot(true);
0037     connect(&m_propertyChangeSignalTimer, &QTimer::timeout, this, &MPrisPlugin::sendPropertyChanges);
0038 
0039     m_possibleLoopStatus = {
0040         {QStringLiteral("None"), false},
0041         {QStringLiteral("Track"), true},
0042         {QStringLiteral("Playlist"), true},
0043     };
0044 }
0045 
0046 bool MPrisPlugin::onUnload()
0047 {
0048     unregisterService();
0049     return true;
0050 }
0051 
0052 // TODO this can surely be done in a much beter way with introspection and what not
0053 void MPrisPlugin::emitPropertyChange(const QDBusAbstractAdaptor *interface, const char *propertyName)
0054 {
0055     // TODO don't assume it's index 0
0056     // TODO figure out encoding encoding
0057     const QString interfaceName = QString::fromUtf8(interface->metaObject()->classInfo(0).value());
0058 
0059     const QMetaProperty prop = metaObject()->property(metaObject()->indexOfProperty(propertyName));
0060 
0061     const QString propertyNameString = QString::fromUtf8(prop.name());
0062     const QVariant value = prop.read(this);
0063 
0064     m_pendingPropertyChanges[interfaceName][propertyNameString] = value;
0065 
0066     if (!m_propertyChangeSignalTimer.isActive()) {
0067         m_propertyChangeSignalTimer.start();
0068     }
0069 }
0070 
0071 void MPrisPlugin::sendPropertyChanges()
0072 {
0073     for (auto it = m_pendingPropertyChanges.constBegin(), end = m_pendingPropertyChanges.constEnd(); it != end; ++it) {
0074         const QString &interfaceName = it.key();
0075 
0076         const QVariantMap &changes = it.value();
0077         if (changes.isEmpty()) {
0078             continue;
0079         }
0080 
0081         QDBusMessage signal = QDBusMessage::createSignal(QStringLiteral("/org/mpris/MediaPlayer2"),
0082                                                          QStringLiteral("org.freedesktop.DBus.Properties"),
0083                                                          QStringLiteral("PropertiesChanged"));
0084 
0085         signal.setArguments({
0086             interfaceName,
0087             changes,
0088             QStringList(), // invalidated
0089         });
0090 
0091         QDBusConnection::sessionBus().send(signal);
0092     }
0093 
0094     m_pendingPropertyChanges.clear();
0095 }
0096 
0097 bool MPrisPlugin::registerService()
0098 {
0099     QString serviceName = s_serviceName;
0100 
0101     if (QDBusConnection::sessionBus().registerService(serviceName)) {
0102         m_serviceName = serviceName;
0103         return true;
0104     }
0105 
0106     // now try appending PID in case multiple hosts are running
0107     serviceName.append(QLatin1String("-")).append(QString::number(QCoreApplication::applicationPid()));
0108 
0109     if (QDBusConnection::sessionBus().registerService(serviceName)) {
0110         m_serviceName = serviceName;
0111         return true;
0112     }
0113 
0114     m_serviceName.clear();
0115     return false;
0116 }
0117 
0118 bool MPrisPlugin::unregisterService()
0119 {
0120     if (m_serviceName.isEmpty()) {
0121         return false;
0122     }
0123     return QDBusConnection::sessionBus().unregisterService(m_serviceName);
0124 }
0125 
0126 void MPrisPlugin::handleData(const QString &event, const QJsonObject &data)
0127 {
0128     if (event == QLatin1String("gone")) {
0129         unregisterService();
0130         setPlaybackStatus(QStringLiteral("Stopped")); // just in case
0131         m_canGoNext = false;
0132         m_canGoPrevious = false;
0133         m_pageTitle.clear();
0134         m_tabTitle.clear();
0135         m_url.clear();
0136         m_mediaSrc.clear();
0137         m_title.clear();
0138         m_artist.clear();
0139         m_artworkUrl.clear();
0140         m_volume = 1.0;
0141         m_muted = false;
0142         m_length = 0;
0143         m_position = 0;
0144     } else if (event == QLatin1String("playing")) {
0145         setPlaybackStatus(QStringLiteral("Playing"));
0146 
0147         m_pageTitle = data.value(QStringLiteral("pageTitle")).toString();
0148         m_tabTitle = data.value(QStringLiteral("tabTitle")).toString();
0149 
0150         m_url = QUrl(data.value(QStringLiteral("url")).toString());
0151         m_mediaSrc = QUrl(data.value(QStringLiteral("mediaSrc")).toString());
0152 
0153         const QUrl posterUrl = QUrl(data.value(QStringLiteral("poster")).toString());
0154         if (m_posterUrl != posterUrl) {
0155             m_posterUrl = posterUrl;
0156             emitPropertyChange(m_player, "Metadata");
0157         }
0158 
0159         const qreal oldVolume = volume();
0160 
0161         m_volume = data.value(QStringLiteral("volume")).toDouble(1);
0162         m_muted = data.value(QStringLiteral("muted")).toBool();
0163 
0164         if (volume() != oldVolume) {
0165             emitPropertyChange(m_player, "Volume");
0166         }
0167 
0168         const qreal length = data.value(QStringLiteral("duration")).toDouble();
0169         // <video> duration is in seconds, mpris uses microseconds
0170         setLength(length * 1000 * 1000);
0171 
0172         const qreal position = data.value(QStringLiteral("currentTime")).toDouble();
0173         setPosition(position * 1000 * 1000);
0174 
0175         const qreal playbackRate = data.value(QStringLiteral("playbackRate")).toDouble(1);
0176         if (m_playbackRate != playbackRate) {
0177             m_playbackRate = playbackRate;
0178             emitPropertyChange(m_player, "Rate");
0179         }
0180 
0181         // check if we're already looping, that keeps us from forcefully
0182         // overwriting Playlist loop with Track loop
0183         const bool oldLoop = m_possibleLoopStatus.value(m_loopStatus);
0184         const bool loop = data.value(QStringLiteral("loop")).toBool();
0185 
0186         if (loop != oldLoop) {
0187             setLoopStatus(loop ? QStringLiteral("Track") : QStringLiteral("None"));
0188         }
0189 
0190         const bool fullscreen = data.value(QStringLiteral("fullscreen")).toBool();
0191         if (m_fullscreen != fullscreen) {
0192             m_fullscreen = fullscreen;
0193             emitPropertyChange(m_root, "Fullscreen");
0194         }
0195 
0196         const bool canSetFullscreen = data.value(QStringLiteral("canSetFullscreen")).toBool();
0197         if (m_canSetFullscreen != canSetFullscreen) {
0198             m_canSetFullscreen = canSetFullscreen;
0199             emitPropertyChange(m_root, "CanSetFullscreen");
0200         }
0201 
0202         processMetadata(data.value(QStringLiteral("metadata")).toObject()); // also emits metadataChanged signal
0203         processCallbacks(data.value(QStringLiteral("callbacks")).toArray());
0204 
0205         registerService();
0206     } else if (event == QLatin1String("paused")) {
0207         setPlaybackStatus(QStringLiteral("Paused"));
0208     } else if (event == QLatin1String("stopped")) {
0209         setPlaybackStatus(QStringLiteral("Stopped"));
0210     } else if (event == QLatin1String("waiting")) {
0211         // unfortunately MPris doesn't have a "Buffering" playback state
0212         // set it to Paused when waiting for the player to avoid the seek slider
0213         // moving while we're not actually playing something
0214         // (we don't get an explicit "paused" signal)
0215         setPlaybackStatus(QStringLiteral("Paused"));
0216     } else if (event == QLatin1String("canplay")) {
0217         // opposite of "waiting", only forwarded by our extension when
0218         // canplay is emitted with the player *not* paused
0219         setPlaybackStatus(QStringLiteral("Playing"));
0220     } else if (event == QLatin1String("duration")) {
0221         const qreal length = data.value(QStringLiteral("duration")).toDouble();
0222 
0223         // <video> duration is in seconds, mpris uses microseconds
0224         setLength(length * 1000 * 1000);
0225     } else if (event == QLatin1String("timeupdate")) {
0226         // not signalling to avoid excess dbus traffic
0227         // media controller asks for this property once when it opens
0228         m_position = data.value(QStringLiteral("currentTime")).toDouble() * 1000 * 1000;
0229     } else if (event == QLatin1String("ratechange")) {
0230         m_playbackRate = data.value(QStringLiteral("playbackRate")).toDouble(1);
0231         emitPropertyChange(m_player, "Rate");
0232     } else if (event == QLatin1String("seeking") || event == QLatin1String("seeked")) {
0233         // seeked is explicit user interaction, signal a change on dbus
0234         const qreal position = data.value(QStringLiteral("currentTime")).toDouble();
0235         // FIXME actually invoke "Seeked" signal
0236         setPosition(position * 1000 * 1000);
0237     } else if (event == QLatin1String("volumechange")) {
0238         m_volume = data.value(QStringLiteral("volume")).toDouble(1);
0239         m_muted = data.value(QStringLiteral("muted")).toBool();
0240         emitPropertyChange(m_player, "Volume");
0241     } else if (event == QLatin1String("metadata")) {
0242         processMetadata(data.value(QStringLiteral("metadata")).toObject());
0243     } else if (event == QLatin1String("callbacks")) {
0244         processCallbacks(data.value(QStringLiteral("callbacks")).toArray());
0245     } else if (event == QLatin1String("titlechange")) {
0246         const QString oldTitle = effectiveTitle();
0247         m_pageTitle = data.value(QStringLiteral("pageTitle")).toString();
0248 
0249         if (oldTitle != effectiveTitle()) {
0250             emitPropertyChange(m_player, "Metadata");
0251         }
0252     } else if (event == QLatin1String("fullscreenchange")) {
0253         const bool fullscreen = data.value(QStringLiteral("fullscreen")).toBool();
0254         if (m_fullscreen != fullscreen) {
0255             m_fullscreen = fullscreen;
0256             emitPropertyChange(m_root, "Fullscreen");
0257         }
0258     } else {
0259         qWarning() << "Don't know how to handle mpris event" << event;
0260     }
0261 }
0262 
0263 QString MPrisPlugin::identity() const
0264 {
0265     return QGuiApplication::applicationDisplayName();
0266 }
0267 
0268 QString MPrisPlugin::desktopEntry() const
0269 {
0270     return QGuiApplication::desktopFileName();
0271 }
0272 
0273 bool MPrisPlugin::canRaise() const
0274 {
0275     return true; // really?
0276 }
0277 
0278 bool MPrisPlugin::fullscreen() const
0279 {
0280     return m_fullscreen;
0281 }
0282 
0283 void MPrisPlugin::setFullscreen(bool fullscreen)
0284 {
0285     sendData(QStringLiteral("setFullscreen"),
0286              {
0287                  {QStringLiteral("fullscreen"), fullscreen},
0288              });
0289 }
0290 
0291 bool MPrisPlugin::canSetFullscreen() const
0292 {
0293     return m_canSetFullscreen;
0294 }
0295 
0296 bool MPrisPlugin::canGoNext() const
0297 {
0298     return m_canGoNext;
0299 }
0300 
0301 bool MPrisPlugin::canGoPrevious() const
0302 {
0303     return m_canGoPrevious;
0304 }
0305 
0306 bool MPrisPlugin::canControl() const
0307 {
0308     return true; // really?
0309 }
0310 
0311 bool MPrisPlugin::canPause() const
0312 {
0313     return canControl();
0314 }
0315 
0316 bool MPrisPlugin::canPlay() const
0317 {
0318     return canControl();
0319 }
0320 
0321 bool MPrisPlugin::canSeek() const
0322 {
0323     // TODO use player.seekable for determining whether we can seek?
0324     return m_length > 0;
0325 }
0326 
0327 qreal MPrisPlugin::volume() const
0328 {
0329     if (m_muted) {
0330         return 0.0;
0331     }
0332     return m_volume;
0333 }
0334 
0335 void MPrisPlugin::setVolume(qreal volume)
0336 {
0337     if (volume < 0) {
0338         volume = 0.0;
0339     }
0340 
0341     sendData(QStringLiteral("setVolume"),
0342              {
0343                  {QStringLiteral("volume"), volume},
0344              });
0345 }
0346 
0347 qlonglong MPrisPlugin::position() const
0348 {
0349     return m_position;
0350 }
0351 
0352 double MPrisPlugin::playbackRate() const
0353 {
0354     return m_playbackRate;
0355 }
0356 
0357 void MPrisPlugin::setPlaybackRate(double playbackRate)
0358 {
0359     if (playbackRate < minimumRate() || playbackRate > maximumRate()) {
0360         return;
0361     }
0362 
0363     sendData(QStringLiteral("setPlaybackRate"),
0364              {
0365                  {QStringLiteral("playbackRate"), playbackRate},
0366              });
0367 }
0368 
0369 double MPrisPlugin::minimumRate() const
0370 {
0371     return 0.01; // don't let it stop
0372 }
0373 
0374 double MPrisPlugin::maximumRate() const
0375 {
0376     return 32; // random
0377 }
0378 
0379 QString MPrisPlugin::playbackStatus() const
0380 {
0381     return m_playbackStatus;
0382 }
0383 
0384 QString MPrisPlugin::loopStatus() const
0385 {
0386     return m_loopStatus;
0387 }
0388 
0389 void MPrisPlugin::setLoopStatus(const QString &loopStatus)
0390 {
0391     if (!m_possibleLoopStatus.contains(loopStatus)) {
0392         return;
0393     }
0394 
0395     sendData(QStringLiteral("setLoop"),
0396              {
0397                  {QStringLiteral("loop"), m_possibleLoopStatus.value(loopStatus)},
0398              });
0399 
0400     m_loopStatus = loopStatus;
0401     emitPropertyChange(m_player, "LoopStatus");
0402 }
0403 
0404 QString MPrisPlugin::effectiveTitle() const
0405 {
0406     if (!m_title.isEmpty()) {
0407         return m_title;
0408     }
0409     if (!m_pageTitle.isEmpty()) {
0410         return m_pageTitle;
0411     }
0412     return m_tabTitle;
0413 }
0414 
0415 QVariantMap MPrisPlugin::metadata() const
0416 {
0417     QVariantMap metadata;
0418 
0419     // HACK this is needed or else SetPosition won't do anything
0420     // TODO use something more sensible, e.g. at least have the tab id with the player in there or so
0421     metadata.insert(QStringLiteral("mpris:trackid"), QVariant::fromValue(QDBusObjectPath(QStringLiteral("/org/kde/plasma/browser_integration/1337"))));
0422 
0423     // Task Manager matches the window to the player's PID but in our case
0424     // the browser window isn't owned by us
0425     metadata.insert(QStringLiteral("kde:pid"), getppid());
0426 
0427     const QString title = effectiveTitle();
0428     if (!title.isEmpty()) {
0429         metadata.insert(QStringLiteral("xesam:title"), title);
0430     }
0431 
0432     if (m_url.isValid()) {
0433         metadata.insert(QStringLiteral("xesam:url"), m_url.toDisplayString());
0434     }
0435     if (m_mediaSrc.isValid()) {
0436         metadata.insert(QStringLiteral("kde:mediaSrc"), m_mediaSrc.toDisplayString());
0437     }
0438     if (m_length > 0) {
0439         metadata.insert(QStringLiteral("mpris:length"), m_length);
0440     }
0441     // See https://searchfox.org/mozilla-central/rev/a3c18883ef9875ba4bb0cc2e7d6ba5a198aaf9bd/dom/media/mediasession/MediaMetadata.h#34
0442     // and
0443     // https://source.chromium.org/chromium/chromium/src/+/main:services/media_session/public/cpp/media_metadata.h;l=46;drc=098756533733ea50b2dcb1c40d9a9e18d49febbe
0444     // MediaMetadata.artist is of string type, but "xesam:artist" is of stringlist type
0445     if (!m_artist.isEmpty()) {
0446         metadata.insert(QStringLiteral("xesam:artist"), QStringList{m_artist});
0447     }
0448 
0449     QUrl artUrl = m_artworkUrl;
0450     if (!artUrl.isValid()) {
0451         artUrl = m_posterUrl;
0452     }
0453     if (artUrl.isValid()) {
0454         metadata.insert(QStringLiteral("mpris:artUrl"), artUrl.toDisplayString());
0455     }
0456 
0457     if (!m_album.isEmpty()) {
0458         metadata.insert(QStringLiteral("xesam:album"), m_album);
0459         // when we don't have artist information use the scheme+domain as "album" (that's what Chrome on Android does)
0460     } else if (m_artist.isEmpty() && m_url.isValid()) {
0461         metadata.insert(QStringLiteral("xesam:album"), m_url.toDisplayString(QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment));
0462     }
0463 
0464     return metadata;
0465 }
0466 
0467 void MPrisPlugin::setPlaybackStatus(const QString &playbackStatus)
0468 {
0469     if (m_playbackStatus != playbackStatus) {
0470         m_playbackStatus = playbackStatus;
0471         // emit playbackStatusChanged();
0472 
0473         emitPropertyChange(m_player, "PlaybackStatus");
0474         // these depend on playback status, so signal a change for these, too
0475         emitPropertyChange(m_player, "CanPlay");
0476         emitPropertyChange(m_player, "CanPause");
0477     }
0478 }
0479 
0480 void MPrisPlugin::setLength(qlonglong length)
0481 {
0482     if (m_length != length) {
0483         const bool oldCanSeek = canSeek();
0484 
0485         m_length = length;
0486         emitPropertyChange(m_player, "Metadata");
0487 
0488         if (oldCanSeek != canSeek()) {
0489             emitPropertyChange(m_player, "CanSeek");
0490         }
0491     }
0492 }
0493 
0494 void MPrisPlugin::setPosition(qlonglong position)
0495 {
0496     if (m_position != position) {
0497         m_position = position;
0498 
0499         emitPropertyChange(m_player, "Position");
0500     }
0501 }
0502 
0503 void MPrisPlugin::processMetadata(const QJsonObject &data)
0504 {
0505     m_title = data.value(QStringLiteral("title")).toString();
0506     m_artist = data.value(QStringLiteral("artist")).toString();
0507     m_album = data.value(QStringLiteral("album")).toString();
0508 
0509     // for simplicity we just use the biggest artwork it offers, perhaps we could limit it to some extent
0510     // TODO download/cache artwork somewhere
0511     QSize biggest;
0512     QUrl artworkUrl;
0513     const QJsonArray &artwork = data.value(QStringLiteral("artwork")).toArray();
0514 
0515     const auto &supportedImageMimes = QImageReader::supportedMimeTypes();
0516 
0517     for (auto it = artwork.constBegin(), end = artwork.constEnd(); it != end; ++it) {
0518         const QJsonObject &item = it->toObject();
0519 
0520         const QUrl url = QUrl(item.value(QStringLiteral("src")).toString());
0521         if (!url.isValid()) {
0522             continue;
0523         }
0524 
0525         // why is this named "sizes" when it's just a string and the examples don't mention how one could specify multiple?
0526         // also, how on Earth could a single image src have multiple sizes? ...
0527         // spec says this is a space-separated list of sizes for ... some reason
0528         const QString sizeString = item.value(QStringLiteral("sizes")).toString();
0529         QSize actualSize;
0530 
0531         // now parse the size...
0532 #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
0533         const auto &sizeParts = sizeString.splitRef(QLatin1Char('x'));
0534 #else
0535         const auto &sizeParts = QStringView(sizeString).split(QLatin1Char('x'));
0536 #endif
0537         if (sizeParts.count() == 2) {
0538             const int width = sizeParts.first().toInt();
0539             const int height = sizeParts.last().toInt();
0540             actualSize = QSize(width, height);
0541         }
0542 
0543         const QString type = item.value(QStringLiteral("type")).toString();
0544         if (!type.isEmpty() && !supportedImageMimes.contains(type.toUtf8())) {
0545             continue;
0546         }
0547 
0548         if (biggest.isEmpty() || (actualSize.width() >= biggest.width() && actualSize.height() >= biggest.height())) {
0549             artworkUrl = url;
0550             biggest = actualSize;
0551         }
0552     }
0553 
0554     m_artworkUrl = artworkUrl;
0555 
0556     emitPropertyChange(m_player, "Metadata");
0557 }
0558 
0559 void MPrisPlugin::processCallbacks(const QJsonArray &data)
0560 {
0561     const bool canGoNext = data.contains(QLatin1String("nexttrack"));
0562     if (m_canGoNext != canGoNext) {
0563         m_canGoNext = canGoNext;
0564         emitPropertyChange(m_player, "CanGoNext");
0565     }
0566 
0567     const bool canGoPrevious = data.contains(QLatin1String("previoustrack"));
0568     if (m_canGoPrevious != canGoPrevious) {
0569         m_canGoPrevious = canGoPrevious;
0570         emitPropertyChange(m_player, "CanGoPrevious");
0571     }
0572 }
0573 
0574 void MPrisPlugin::Raise()
0575 {
0576     sendData(QStringLiteral("raise"));
0577 }
0578 
0579 void MPrisPlugin::Quit()
0580 {
0581 }
0582 
0583 void MPrisPlugin::Next()
0584 {
0585     if (!m_canGoNext) {
0586         return;
0587     }
0588     sendData(QStringLiteral("next"));
0589 }
0590 
0591 void MPrisPlugin::Previous()
0592 {
0593     if (!m_canGoPrevious) {
0594         return;
0595     }
0596     sendData(QStringLiteral("previous"));
0597 }
0598 
0599 void MPrisPlugin::Pause()
0600 {
0601     sendData(QStringLiteral("pause"));
0602 }
0603 
0604 void MPrisPlugin::PlayPause()
0605 {
0606     sendData(QStringLiteral("playPause"));
0607 }
0608 
0609 void MPrisPlugin::Stop()
0610 {
0611     sendData(QStringLiteral("stop"));
0612 }
0613 
0614 void MPrisPlugin::Play()
0615 {
0616     sendData(QStringLiteral("play"));
0617 }
0618 
0619 void MPrisPlugin::Seek(qlonglong offset)
0620 {
0621     auto newPosition = position() + offset;
0622     if (newPosition >= m_length) {
0623         Next();
0624         return;
0625     }
0626 
0627     if (newPosition < 0) {
0628         newPosition = 0;
0629     }
0630     SetPosition(QDBusObjectPath() /*unused*/, newPosition);
0631 }
0632 
0633 void MPrisPlugin::SetPosition(const QDBusObjectPath &path, qlonglong position)
0634 {
0635     Q_UNUSED(path); // TODO use?
0636 
0637     if (position < 0 || position >= m_length) {
0638         return;
0639     }
0640 
0641     sendData(QStringLiteral("setPosition"), {{QStringLiteral("position"), position / 1000.0 / 1000.0}});
0642 }
0643 
0644 void MPrisPlugin::OpenUri(const QString &uri)
0645 {
0646     Q_UNUSED(uri);
0647 }