mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-02-10 19:52:24 +00:00
[Linux] Use DBus for following media playback change
This commit is contained in:
committed by
Tim Gromeyer
parent
5754dbfb16
commit
38d6f8ceae
257
linux/media/mediacontroller.cpp
Normal file
257
linux/media/mediacontroller.cpp
Normal 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();
|
||||
}
|
||||
61
linux/media/mediacontroller.h
Normal file
61
linux/media/mediacontroller.h
Normal 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
|
||||
47
linux/media/playerstatuswatcher.cpp
Normal file
47
linux/media/playerstatuswatcher.cpp
Normal 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
|
||||
}
|
||||
}
|
||||
24
linux/media/playerstatuswatcher.h
Normal file
24
linux/media/playerstatuswatcher.h
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user