[Linux] Use DBus for following media playback change

This commit is contained in:
Tim Gromeyer
2025-06-08 18:27:26 +02:00
committed by Tim Gromeyer
parent 5754dbfb16
commit 38d6f8ceae
6 changed files with 85 additions and 82 deletions

View File

@@ -0,0 +1,257 @@
#include "mediacontroller.h"
#include "logger.h"
#include "eardetection.hpp"
#include "playerstatuswatcher.h"
#include <QDebug>
#include <QProcess>
#include <QRegularExpression>
#include <QDBusConnection>
#include <QDBusConnectionInterface>
MediaController::MediaController(QObject *parent) : QObject(parent) {
}
void MediaController::handleEarDetection(EarDetection *earDetection)
{
if (earDetectionBehavior == Disabled)
{
LOG_DEBUG("Ear detection is disabled, ignoring status");
return;
}
bool primaryInEar = earDetection->isPrimaryInEar();
bool secondaryInEar = earDetection->isSecondaryInEar();
LOG_DEBUG("Ear detection status: primaryInEar="
<< primaryInEar << ", secondaryInEar=" << secondaryInEar
<< ", isAirPodsActive=" << isActiveOutputDeviceAirPods());
// First handle playback pausing based on selected behavior
bool shouldPause = false;
bool shouldResume = false;
if (earDetectionBehavior == PauseWhenOneRemoved)
{
shouldPause = !primaryInEar || !secondaryInEar;
shouldResume = primaryInEar && secondaryInEar;
}
else if (earDetectionBehavior == PauseWhenBothRemoved)
{
shouldPause = !primaryInEar && !secondaryInEar;
shouldResume = primaryInEar || secondaryInEar;
}
if (shouldPause && isActiveOutputDeviceAirPods())
{
QProcess process;
process.start("playerctl", QStringList() << "status");
process.waitForFinished();
QString playbackStatus = process.readAllStandardOutput().trimmed();
LOG_DEBUG("Playback status: " << playbackStatus);
if (playbackStatus == "Playing")
{
pause();
}
}
// Then handle device profile switching
if (primaryInEar || secondaryInEar)
{
LOG_INFO("At least one AirPod is in ear");
activateA2dpProfile();
// Resume if conditions are met and we previously paused
if (shouldResume && wasPausedByApp && isActiveOutputDeviceAirPods())
{
int result = QProcess::execute("playerctl", QStringList() << "play");
LOG_DEBUG("Executed 'playerctl play' with result: " << result);
if (result == 0)
{
LOG_INFO("Resumed playback via Playerctl");
wasPausedByApp = false;
}
else
{
LOG_ERROR("Failed to resume playback via Playerctl");
}
}
}
else
{
LOG_INFO("Both AirPods are out of ear");
removeAudioOutputDevice();
}
}
void MediaController::setEarDetectionBehavior(EarDetectionBehavior behavior)
{
earDetectionBehavior = behavior;
LOG_INFO("Set ear detection behavior to: " << behavior);
}
void MediaController::followMediaChanges() {
playerStatusWatcher = new PlayerStatusWatcher("", this);
connect(playerStatusWatcher, &PlayerStatusWatcher::playbackStatusChanged,
this, [this](const QString &status)
{
LOG_DEBUG("Playback status changed: " << status);
MediaState state = mediaStateFromPlayerctlOutput(status);
emit mediaStateChanged(state);
});
}
bool MediaController::isActiveOutputDeviceAirPods() {
QProcess process;
process.start("pactl", QStringList() << "get-default-sink");
process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed();
LOG_DEBUG("Default sink: " << output);
return output.contains(connectedDeviceMacAddress);
}
void MediaController::handleConversationalAwareness(const QByteArray &data) {
LOG_DEBUG("Handling conversational awareness data: " << data.toHex());
bool lowered = data[9] == 0x01;
LOG_INFO("Conversational awareness: " << (lowered ? "enabled" : "disabled"));
if (lowered) {
if (initialVolume == -1 && isActiveOutputDeviceAirPods()) {
QProcess process;
process.start("pactl", QStringList()
<< "get-sink-volume" << "@DEFAULT_SINK@");
process.waitForFinished();
QString output = process.readAllStandardOutput();
QRegularExpression re("front-left: \\d+ /\\s*(\\d+)%");
QRegularExpressionMatch match = re.match(output);
if (match.hasMatch()) {
LOG_DEBUG("Matched: " << match.captured(1));
initialVolume = match.captured(1).toInt();
} else {
LOG_ERROR("Failed to parse initial volume from output: " << output);
return;
}
}
QProcess::execute(
"pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@"
<< QString::number(initialVolume * 0.20) + "%");
LOG_INFO("Volume lowered to 0.20 of initial which is "
<< initialVolume * 0.20 << "%");
} else {
if (initialVolume != -1 && isActiveOutputDeviceAirPods()) {
QProcess::execute("pactl", QStringList()
<< "set-sink-volume" << "@DEFAULT_SINK@"
<< QString::number(initialVolume) + "%");
LOG_INFO("Volume restored to " << initialVolume << "%");
initialVolume = -1;
}
}
}
void MediaController::activateA2dpProfile() {
if (connectedDeviceMacAddress.isEmpty() || m_deviceOutputName.isEmpty()) {
LOG_WARN("Connected device MAC address or output name is empty, cannot activate A2DP profile");
return;
}
LOG_INFO("Activating A2DP profile for AirPods");
int result = QProcess::execute(
"pactl", QStringList()
<< "set-card-profile"
<< m_deviceOutputName << "a2dp-sink");
if (result != 0) {
LOG_ERROR("Failed to activate A2DP profile");
}
}
void MediaController::removeAudioOutputDevice() {
if (connectedDeviceMacAddress.isEmpty() || m_deviceOutputName.isEmpty()) {
LOG_WARN("Connected device MAC address or output name is empty, cannot remove audio output device");
return;
}
LOG_INFO("Removing AirPods as audio output device");
int result = QProcess::execute(
"pactl", QStringList()
<< "set-card-profile"
<< m_deviceOutputName << "off");
if (result != 0) {
LOG_ERROR("Failed to remove AirPods as audio output device");
}
}
void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) {
connectedDeviceMacAddress = macAddress;
m_deviceOutputName = getAudioDeviceName();
LOG_INFO("Device output name set to: " << m_deviceOutputName);
}
MediaController::MediaState MediaController::mediaStateFromPlayerctlOutput(
const QString &output) {
if (output == "Playing") {
return MediaState::Playing;
} else if (output == "Paused") {
return MediaState::Paused;
} else {
return MediaState::Stopped;
}
}
void MediaController::pause() {
int result = QProcess::execute("playerctl", QStringList() << "pause");
LOG_DEBUG("Executed 'playerctl pause' with result: " << result);
if (result == 0)
{
LOG_INFO("Paused playback via Playerctl");
wasPausedByApp = true;
}
else
{
LOG_ERROR("Failed to pause playback via Playerctl");
}
}
MediaController::~MediaController() {
}
QString MediaController::getAudioDeviceName()
{
if (connectedDeviceMacAddress.isEmpty()) { return QString(); }
// Set up QProcess to run pactl directly
QProcess process;
process.start("pactl", QStringList() << "list" << "cards" << "short");
if (!process.waitForFinished(3000)) // Timeout after 3 seconds
{
LOG_ERROR("pactl command failed or timed out: " << process.errorString());
return QString();
}
// Check for execution errors
if (process.exitCode() != 0)
{
LOG_ERROR("pactl exited with error code: " << process.exitCode());
return QString();
}
// Read and parse the command output
QString output = process.readAllStandardOutput();
QStringList lines = output.split("\n", Qt::SkipEmptyParts);
// Iterate through each line to find a matching Bluetooth sink
for (const QString &line : lines)
{
QStringList fields = line.split("\t", Qt::SkipEmptyParts);
if (fields.size() < 2) { continue; }
QString sinkName = fields[1].trimmed();
if (sinkName.startsWith("bluez") && sinkName.contains(connectedDeviceMacAddress))
{
return sinkName;
}
}
// No matching sink found
LOG_ERROR("No matching Bluetooth sink found for MAC address: " << connectedDeviceMacAddress);
return QString();
}

View File

@@ -0,0 +1,61 @@
#ifndef MEDIACONTROLLER_H
#define MEDIACONTROLLER_H
#include <QDBusInterface>
#include <QObject>
class QProcess;
class EarDetection;
class PlayerStatusWatcher;
class MediaController : public QObject
{
Q_OBJECT
public:
enum MediaState
{
Playing,
Paused,
Stopped
};
Q_ENUM(MediaState)
enum EarDetectionBehavior
{
PauseWhenOneRemoved,
PauseWhenBothRemoved,
Disabled
};
Q_ENUM(EarDetectionBehavior)
explicit MediaController(QObject *parent = nullptr);
~MediaController();
void handleEarDetection(EarDetection*);
void followMediaChanges();
bool isActiveOutputDeviceAirPods();
void handleConversationalAwareness(const QByteArray &data);
void activateA2dpProfile();
void removeAudioOutputDevice();
void setConnectedDeviceMacAddress(const QString &macAddress);
void setEarDetectionBehavior(EarDetectionBehavior behavior);
inline EarDetectionBehavior getEarDetectionBehavior() const { return earDetectionBehavior; }
void pause();
Q_SIGNALS:
void mediaStateChanged(MediaState state);
private:
MediaState mediaStateFromPlayerctlOutput(const QString &output);
QString getAudioDeviceName();
bool wasPausedByApp = false;
int initialVolume = -1;
QString connectedDeviceMacAddress;
EarDetectionBehavior earDetectionBehavior = PauseWhenOneRemoved;
QString m_deviceOutputName;
PlayerStatusWatcher *playerStatusWatcher = nullptr;
};
#endif // MEDIACONTROLLER_H

View File

@@ -0,0 +1,47 @@
#include "playerstatuswatcher.h"
#include <QDBusConnection>
#include <QDBusPendingReply>
#include <QVariantMap>
#include <QDBusReply>
PlayerStatusWatcher::PlayerStatusWatcher(const QString &playerService, QObject *parent)
: QObject(parent),
m_playerService(playerService),
m_iface(new QDBusInterface(playerService, "/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player", QDBusConnection::sessionBus(), this)),
m_serviceWatcher(new QDBusServiceWatcher(playerService, QDBusConnection::sessionBus(),
QDBusServiceWatcher::WatchForOwnerChange, this))
{
QDBusConnection::sessionBus().connect(
playerService, "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties",
"PropertiesChanged", this, SLOT(onPropertiesChanged(QString,QVariantMap,QStringList))
);
connect(m_serviceWatcher, &QDBusServiceWatcher::serviceOwnerChanged,
this, &PlayerStatusWatcher::onServiceOwnerChanged);
updateStatus();
}
void PlayerStatusWatcher::onPropertiesChanged(const QString &interface,
const QVariantMap &changed,
const QStringList &)
{
if (interface == "org.mpris.MediaPlayer2.Player" && changed.contains("PlaybackStatus")) {
emit playbackStatusChanged(changed.value("PlaybackStatus").toString());
}
}
void PlayerStatusWatcher::updateStatus() {
QVariant reply = m_iface->property("PlaybackStatus");
if (reply.isValid()) {
emit playbackStatusChanged(reply.toString());
}
}
void PlayerStatusWatcher::onServiceOwnerChanged(const QString &name, const QString &, const QString &newOwner)
{
if (name == m_playerService && newOwner.isEmpty()) {
emit playbackStatusChanged(""); // player disappeared
} else if (name == m_playerService && !newOwner.isEmpty()) {
updateStatus(); // player appeared/reappeared
}
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include <QObject>
#include <QDBusInterface>
#include <QDBusServiceWatcher>
class PlayerStatusWatcher : public QObject {
Q_OBJECT
public:
explicit PlayerStatusWatcher(const QString &playerService, QObject *parent = nullptr);
signals:
void playbackStatusChanged(const QString &status);
private slots:
void onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &);
void onServiceOwnerChanged(const QString &name, const QString &oldOwner, const QString &newOwner);
private:
void updateStatus();
QString m_playerService;
QDBusInterface *m_iface;
QDBusServiceWatcher *m_serviceWatcher;
};