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 }