mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-29 01:29:49 +00:00
Merge remote-tracking branch 'refs/remotes/origin/main'
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -657,3 +657,4 @@ obj/
|
|||||||
!/gradle/wrapper/gradle-wrapper.jar
|
!/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux
|
# End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux
|
||||||
|
linux/.qmlls.ini
|
||||||
|
|||||||
@@ -4,12 +4,20 @@ project(linux VERSION 0.1 LANGUAGES CXX)
|
|||||||
|
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
find_package(Qt6 6.5 REQUIRED COMPONENTS Quick Widgets Bluetooth Multimedia DBus)
|
find_package(Qt6 6.5 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
|
||||||
|
|
||||||
qt_standard_project_setup(REQUIRES 6.5)
|
qt_standard_project_setup(REQUIRES 6.5)
|
||||||
|
|
||||||
qt_add_executable(applinux
|
qt_add_executable(applinux
|
||||||
main.cpp
|
main.cpp
|
||||||
|
main.h
|
||||||
|
logger.h
|
||||||
|
mediacontroller.cpp
|
||||||
|
mediacontroller.h
|
||||||
|
airpods_packets.h
|
||||||
|
trayiconmanager.cpp
|
||||||
|
trayiconmanager.h
|
||||||
|
enums.h
|
||||||
)
|
)
|
||||||
|
|
||||||
qt_add_qml_module(applinux
|
qt_add_qml_module(applinux
|
||||||
@@ -19,8 +27,15 @@ qt_add_qml_module(applinux
|
|||||||
Main.qml
|
Main.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add the resource file
|
||||||
|
qt_add_resources(applinux "resources"
|
||||||
|
PREFIX "/icons"
|
||||||
|
FILES
|
||||||
|
assets/airpods.png
|
||||||
|
)
|
||||||
|
|
||||||
target_link_libraries(applinux
|
target_link_libraries(applinux
|
||||||
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::Multimedia Qt6::DBus
|
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus
|
||||||
)
|
)
|
||||||
|
|
||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|||||||
@@ -6,58 +6,35 @@ ApplicationWindow {
|
|||||||
width: 400
|
width: 400
|
||||||
height: 300
|
height: 300
|
||||||
title: "AirPods Settings"
|
title: "AirPods Settings"
|
||||||
property bool ignoreNoiseControlChange: false
|
|
||||||
property bool isPlaying: false
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
|
||||||
caToggle.checked = airPodsTrayApp.loadConversationalAwarenessState()
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
spacing: 20
|
spacing: 20
|
||||||
padding: 20
|
padding: 20
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "Battery Status: "
|
|
||||||
id: batteryStatus
|
id: batteryStatus
|
||||||
objectName: "batteryStatus"
|
text: "Battery Status: " + airPodsTrayApp.batteryStatus
|
||||||
color: "#ffffff"
|
color: "#ffffff"
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "Ear Detection Status: "
|
|
||||||
id: earDetectionStatus
|
id: earDetectionStatus
|
||||||
objectName: "earDetectionStatus"
|
text: "Ear Detection Status: " + airPodsTrayApp.earDetectionStatus
|
||||||
color: "#ffffff"
|
color: "#ffffff"
|
||||||
}
|
}
|
||||||
|
|
||||||
ComboBox {
|
ComboBox {
|
||||||
id: noiseControlMode
|
id: noiseControlMode
|
||||||
model: ["Off", "Noise Cancellation", "Transparency", "Adaptive"]
|
model: ["Off", "Noise Cancellation", "Transparency", "Adaptive"]
|
||||||
currentIndex: 0
|
currentIndex: airPodsTrayApp.noiseControlMode
|
||||||
onCurrentIndexChanged: {
|
onCurrentIndexChanged: airPodsTrayApp.noiseControlMode = currentIndex
|
||||||
if (!ignoreNoiseControlChange) {
|
|
||||||
airPodsTrayApp.setNoiseControlMode(currentIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Connections {
|
|
||||||
target: airPodsTrayApp
|
|
||||||
onNoiseControlModeChanged: {
|
|
||||||
ignoreNoiseControlChange = true
|
|
||||||
noiseControlMode.currentIndex = mode;
|
|
||||||
ignoreNoiseControlChange = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Switch {
|
Switch {
|
||||||
id: caToggle
|
id: caToggle
|
||||||
text: "Conversational Awareness"
|
text: "Conversational Awareness"
|
||||||
checked: isPlaying
|
checked: airPodsTrayApp.conversationalAwareness
|
||||||
onCheckedChanged: {
|
onCheckedChanged: airPodsTrayApp.conversationalAwareness = checked
|
||||||
airPodsTrayApp.setConversationalAwareness(checked)
|
|
||||||
airPodsTrayApp.saveConversationalAwarenessState(checked)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
55
linux/airpods_packets.h
Normal file
55
linux/airpods_packets.h
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// airpods_packets.h
|
||||||
|
#ifndef AIRPODS_PACKETS_H
|
||||||
|
#define AIRPODS_PACKETS_H
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
|
||||||
|
namespace AirPodsPackets
|
||||||
|
{
|
||||||
|
// Noise Control Mode Packets
|
||||||
|
namespace NoiseControl
|
||||||
|
{
|
||||||
|
static const QByteArray HEADER = QByteArray::fromHex("0400040009000D"); // Added for parsing
|
||||||
|
static const QByteArray OFF = HEADER + QByteArray::fromHex("01000000");
|
||||||
|
static const QByteArray NOISE_CANCELLATION = HEADER + QByteArray::fromHex("02000000");
|
||||||
|
static const QByteArray TRANSPARENCY = HEADER + QByteArray::fromHex("03000000");
|
||||||
|
static const QByteArray ADAPTIVE = HEADER + QByteArray::fromHex("04000000");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversational Awareness Packets
|
||||||
|
namespace ConversationalAwareness
|
||||||
|
{
|
||||||
|
static const QByteArray HEADER = QByteArray::fromHex("04000400090028"); // Added for parsing
|
||||||
|
static const QByteArray ENABLED = HEADER + QByteArray::fromHex("01000000");
|
||||||
|
static const QByteArray DISABLED = HEADER + QByteArray::fromHex("02000000");
|
||||||
|
static const QByteArray DATA_HEADER = QByteArray::fromHex("040004004B00020001"); // For received data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection Packets
|
||||||
|
namespace Connection
|
||||||
|
{
|
||||||
|
static const QByteArray HANDSHAKE = QByteArray::fromHex("00000400010002000000000000000000");
|
||||||
|
static const QByteArray SET_SPECIFIC_FEATURES = QByteArray::fromHex("040004004d00ff00000000000000");
|
||||||
|
static const QByteArray REQUEST_NOTIFICATIONS = QByteArray::fromHex("040004000f00ffffffffff");
|
||||||
|
static const QByteArray AIRPODS_DISCONNECTED = QByteArray::fromHex("00010000");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone Communication Packets
|
||||||
|
namespace Phone
|
||||||
|
{
|
||||||
|
static const QByteArray NOTIFICATION = QByteArray::fromHex("00040001");
|
||||||
|
static const QByteArray CONNECTED = QByteArray::fromHex("00010001");
|
||||||
|
static const QByteArray DISCONNECTED = QByteArray::fromHex("00010000");
|
||||||
|
static const QByteArray STATUS_REQUEST = QByteArray::fromHex("00020003");
|
||||||
|
static const QByteArray DISCONNECT_REQUEST = QByteArray::fromHex("00020000");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing Headers
|
||||||
|
namespace Parse
|
||||||
|
{
|
||||||
|
static const QByteArray EAR_DETECTION = QByteArray::fromHex("040004000600");
|
||||||
|
static const QByteArray BATTERY_STATUS = QByteArray::fromHex("040004000400");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // AIRPODS_PACKETS_H
|
||||||
BIN
linux/assets/airpods.png
Normal file
BIN
linux/assets/airpods.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 605 KiB |
23
linux/enums.h
Normal file
23
linux/enums.h
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QMetaType>
|
||||||
|
|
||||||
|
namespace AirpodsTrayApp
|
||||||
|
{
|
||||||
|
namespace Enums
|
||||||
|
{
|
||||||
|
Q_NAMESPACE
|
||||||
|
|
||||||
|
enum class NoiseControlMode : quint8
|
||||||
|
{
|
||||||
|
Off = 0,
|
||||||
|
NoiseCancellation = 1,
|
||||||
|
Transparency = 2,
|
||||||
|
Adaptive = 3,
|
||||||
|
|
||||||
|
MinValue = Off,
|
||||||
|
MaxValue = Adaptive,
|
||||||
|
};
|
||||||
|
Q_ENUM_NS(NoiseControlMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
linux/logger.h
Normal file
11
linux/logger.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QLoggingCategory>
|
||||||
|
|
||||||
|
Q_DECLARE_LOGGING_CATEGORY(airpodsApp)
|
||||||
|
|
||||||
|
#define LOG_INFO(msg) qCInfo(airpodsApp) << "\033[32m" << msg << "\033[0m"
|
||||||
|
#define LOG_WARN(msg) qCWarning(airpodsApp) << "\033[33m" << msg << "\033[0m"
|
||||||
|
#define LOG_ERROR(msg) qCCritical(airpodsApp) << "\033[31m" << msg << "\033[0m"
|
||||||
|
#define LOG_DEBUG(msg) qCDebug(airpodsApp) << "\033[34m" << msg << "\033[0m"
|
||||||
619
linux/main.cpp
619
linux/main.cpp
@@ -1,31 +1,24 @@
|
|||||||
|
#include <QSettings>
|
||||||
|
|
||||||
#include "main.h"
|
#include "main.h"
|
||||||
|
#include "airpods_packets.h"
|
||||||
|
#include "logger.h"
|
||||||
|
#include "mediacontroller.h"
|
||||||
|
#include "trayiconmanager.h"
|
||||||
|
#include "enums.h"
|
||||||
|
|
||||||
#define LOG_INFO(msg) qCInfo(airpodsApp) << "\033[32m" << msg << "\033[0m"
|
using namespace AirpodsTrayApp::Enums;
|
||||||
#define LOG_WARN(msg) qCWarning(airpodsApp) << "\033[33m" << msg << "\033[0m"
|
|
||||||
#define LOG_ERROR(msg) qCCritical(airpodsApp) << "\033[31m" << msg << "\033[0m"
|
|
||||||
#define LOG_DEBUG(msg) qCDebug(airpodsApp) << "\033[34m" << msg << "\033[0m"
|
|
||||||
|
|
||||||
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
|
Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
|
||||||
|
|
||||||
#define MANUFACTURER_ID 0x1234
|
|
||||||
#define MANUFACTURER_DATA "ALN_AirPods"
|
|
||||||
|
|
||||||
class AirPodsTrayApp : public QObject {
|
class AirPodsTrayApp : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(QString batteryStatus READ batteryStatus NOTIFY batteryStatusChanged)
|
||||||
|
Q_PROPERTY(QString earDetectionStatus READ earDetectionStatus NOTIFY earDetectionStatusChanged)
|
||||||
|
Q_PROPERTY(int noiseControlMode READ noiseControlMode WRITE setNoiseControlMode NOTIFY noiseControlModeChanged)
|
||||||
|
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
enum NoiseControlMode : quint8
|
|
||||||
{
|
|
||||||
Off = 0,
|
|
||||||
NoiseCancellation = 1,
|
|
||||||
Transparency = 2,
|
|
||||||
Adaptive = 3,
|
|
||||||
|
|
||||||
MinValue = Off,
|
|
||||||
MaxValue = Adaptive,
|
|
||||||
};
|
|
||||||
Q_ENUM(NoiseControlMode)
|
|
||||||
|
|
||||||
AirPodsTrayApp(bool debugMode) : debugMode(debugMode) {
|
AirPodsTrayApp(bool debugMode) : debugMode(debugMode) {
|
||||||
if (debugMode) {
|
if (debugMode) {
|
||||||
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
|
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
|
||||||
@@ -33,65 +26,25 @@ public:
|
|||||||
QLoggingCategory::setFilterRules("airpodsApp.debug=false");
|
QLoggingCategory::setFilterRules("airpodsApp.debug=false");
|
||||||
}
|
}
|
||||||
LOG_INFO("Initializing AirPodsTrayApp");
|
LOG_INFO("Initializing AirPodsTrayApp");
|
||||||
trayIcon = new QSystemTrayIcon(QIcon(":/icons/airpods.png"));
|
|
||||||
trayMenu = new QMenu();
|
|
||||||
|
|
||||||
bool caState = loadConversationalAwarenessState();
|
// Initialize tray icon and connect signals
|
||||||
QAction *caToggleAction = new QAction("Toggle Conversational Awareness", trayMenu);
|
trayManager = new TrayIconManager(this);
|
||||||
caToggleAction->setCheckable(true);
|
connect(trayManager, &TrayIconManager::trayClicked, this, &AirPodsTrayApp::onTrayIconActivated);
|
||||||
caToggleAction->setChecked(caState);
|
connect(trayManager, &TrayIconManager::noiseControlChanged, this, qOverload<NoiseControlMode>(&AirPodsTrayApp::setNoiseControlMode));
|
||||||
connect(caToggleAction, &QAction::triggered, this, [this, caToggleAction]() {
|
connect(trayManager, &TrayIconManager::conversationalAwarenessToggled, this, &AirPodsTrayApp::setConversationalAwareness);
|
||||||
bool newState = !caToggleAction->isChecked();
|
connect(this, &AirPodsTrayApp::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus);
|
||||||
setConversationalAwareness(newState);
|
connect(this, &AirPodsTrayApp::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState);
|
||||||
saveConversationalAwarenessState(newState);
|
connect(this, &AirPodsTrayApp::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness);
|
||||||
caToggleAction->setChecked(newState);
|
|
||||||
});
|
|
||||||
trayMenu->addAction(caToggleAction);
|
|
||||||
|
|
||||||
QAction *offAction = new QAction("Off", trayMenu);
|
// Initialize MediaController and connect signals
|
||||||
QAction *transparencyAction = new QAction("Transparency", trayMenu);
|
mediaController = new MediaController(this);
|
||||||
QAction *adaptiveAction = new QAction("Adaptive", trayMenu);
|
connect(this, &AirPodsTrayApp::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
|
||||||
QAction *noiseCancellationAction = new QAction("Noise Cancellation", trayMenu);
|
connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange);
|
||||||
|
mediaController->initializeMprisInterface();
|
||||||
|
mediaController->followMediaChanges();
|
||||||
|
|
||||||
offAction->setData(NoiseControlMode::Off);
|
// load conversational awareness state
|
||||||
transparencyAction->setData(NoiseControlMode::Transparency);
|
setConversationalAwareness(loadConversationalAwarenessState());
|
||||||
adaptiveAction->setData(NoiseControlMode::Adaptive);
|
|
||||||
noiseCancellationAction->setData(NoiseControlMode::NoiseCancellation);
|
|
||||||
|
|
||||||
offAction->setCheckable(true);
|
|
||||||
transparencyAction->setCheckable(true);
|
|
||||||
adaptiveAction->setCheckable(true);
|
|
||||||
noiseCancellationAction->setCheckable(true);
|
|
||||||
|
|
||||||
trayMenu->addAction(offAction);
|
|
||||||
trayMenu->addAction(transparencyAction);
|
|
||||||
trayMenu->addAction(adaptiveAction);
|
|
||||||
trayMenu->addAction(noiseCancellationAction);
|
|
||||||
|
|
||||||
QActionGroup *noiseControlGroup = new QActionGroup(trayMenu);
|
|
||||||
noiseControlGroup->addAction(offAction);
|
|
||||||
noiseControlGroup->addAction(transparencyAction);
|
|
||||||
noiseControlGroup->addAction(adaptiveAction);
|
|
||||||
noiseControlGroup->addAction(noiseCancellationAction);
|
|
||||||
|
|
||||||
connect(offAction, &QAction::triggered, this, [this]()
|
|
||||||
{ setNoiseControlMode(NoiseControlMode::Off); });
|
|
||||||
connect(transparencyAction, &QAction::triggered, this, [this]()
|
|
||||||
{ setNoiseControlMode(NoiseControlMode::Transparency); });
|
|
||||||
connect(adaptiveAction, &QAction::triggered, this, [this]()
|
|
||||||
{ setNoiseControlMode(NoiseControlMode::Adaptive); });
|
|
||||||
connect(noiseCancellationAction, &QAction::triggered, this, [this]()
|
|
||||||
{ setNoiseControlMode(NoiseControlMode::NoiseCancellation); });
|
|
||||||
|
|
||||||
connect(this, &AirPodsTrayApp::noiseControlModeChanged, this, &AirPodsTrayApp::updateNoiseControlMenu);
|
|
||||||
connect(this, &AirPodsTrayApp::batteryStatusChanged, this, &AirPodsTrayApp::updateBatteryTooltip);
|
|
||||||
connect(this, &AirPodsTrayApp::batteryStatusChanged, this, &AirPodsTrayApp::updateTrayIcon);
|
|
||||||
connect(this, &AirPodsTrayApp::earDetectionStatusChanged, this, &AirPodsTrayApp::handleEarDetection);
|
|
||||||
|
|
||||||
trayIcon->setContextMenu(trayMenu);
|
|
||||||
trayIcon->show();
|
|
||||||
|
|
||||||
connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated);
|
|
||||||
|
|
||||||
discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
|
discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
|
||||||
discoveryAgent->setLowEnergyDiscoveryTimeout(15000);
|
discoveryAgent->setLowEnergyDiscoveryTimeout(15000);
|
||||||
@@ -113,7 +66,6 @@ public:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
initializeMprisInterface();
|
|
||||||
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived);
|
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived);
|
||||||
|
|
||||||
QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1");
|
QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1");
|
||||||
@@ -139,6 +91,11 @@ public:
|
|||||||
delete phoneSocket;
|
delete phoneSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString batteryStatus() const { return m_batteryStatus; }
|
||||||
|
QString earDetectionStatus() const { return m_earDetectionStatus; }
|
||||||
|
int noiseControlMode() const { return static_cast<int>(m_noiseControlMode); }
|
||||||
|
bool conversationalAwareness() const { return m_conversationalAwareness; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool debugMode;
|
bool debugMode;
|
||||||
bool isConnectedLocally = false;
|
bool isConnectedLocally = false;
|
||||||
@@ -187,16 +144,18 @@ private:
|
|||||||
QDBusConnection::systemBus().registerService("me.kavishdevar.aln");
|
QDBusConnection::systemBus().registerService("me.kavishdevar.aln");
|
||||||
}
|
}
|
||||||
|
|
||||||
void notifyAndroidDevice() {
|
void notifyAndroidDevice()
|
||||||
if (phoneSocket && phoneSocket->isOpen()) {
|
{
|
||||||
QByteArray notificationPacket = QByteArray::fromHex("00040001");
|
if (phoneSocket && phoneSocket->isOpen())
|
||||||
phoneSocket->write(notificationPacket);
|
{
|
||||||
LOG_DEBUG("Sent notification packet to Android: " << notificationPacket.toHex());
|
phoneSocket->write(AirPodsPackets::Phone::NOTIFICATION);
|
||||||
} else {
|
LOG_DEBUG("Sent notification packet to Android: " << AirPodsPackets::Phone::NOTIFICATION.toHex());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
LOG_WARN("Phone socket is not open, cannot send notification packet");
|
LOG_WARN("Phone socket is not open, cannot send notification packet");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onNameOwnerChanged(const QString &name, const QString &oldOwner, const QString &newOwner) {
|
void onNameOwnerChanged(const QString &name, const QString &oldOwner, const QString &newOwner) {
|
||||||
if (name == "org.bluez") {
|
if (name == "org.bluez") {
|
||||||
if (newOwner.isEmpty()) {
|
if (newOwner.isEmpty()) {
|
||||||
@@ -257,180 +216,88 @@ public slots:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setNoiseControlMode(NoiseControlMode mode) {
|
void setNoiseControlMode(NoiseControlMode mode)
|
||||||
|
{
|
||||||
LOG_INFO("Setting noise control mode to: " << mode);
|
LOG_INFO("Setting noise control mode to: " << mode);
|
||||||
QByteArray packet;
|
QByteArray packet;
|
||||||
switch (mode) {
|
switch (mode)
|
||||||
case Off:
|
{
|
||||||
packet = QByteArray::fromHex("0400040009000D01000000");
|
case NoiseControlMode::Off:
|
||||||
break;
|
packet = AirPodsPackets::NoiseControl::OFF;
|
||||||
case NoiseCancellation:
|
break;
|
||||||
packet = QByteArray::fromHex("0400040009000D02000000");
|
case NoiseControlMode::NoiseCancellation:
|
||||||
break;
|
packet = AirPodsPackets::NoiseControl::NOISE_CANCELLATION;
|
||||||
case Transparency:
|
break;
|
||||||
packet = QByteArray::fromHex("0400040009000D03000000");
|
case NoiseControlMode::Transparency:
|
||||||
break;
|
packet = AirPodsPackets::NoiseControl::TRANSPARENCY;
|
||||||
case Adaptive:
|
break;
|
||||||
packet = QByteArray::fromHex("0400040009000D04000000");
|
case NoiseControlMode::Adaptive:
|
||||||
break;
|
packet = AirPodsPackets::NoiseControl::ADAPTIVE;
|
||||||
}
|
break;
|
||||||
if (socket && socket->isOpen()) {
|
|
||||||
socket->write(packet);
|
|
||||||
LOG_DEBUG("Noise control mode packet written: " << packet.toHex());
|
|
||||||
} else {
|
|
||||||
LOG_ERROR("Socket is not open, cannot write noise control mode packet");
|
|
||||||
}
|
}
|
||||||
|
writePacketToSocket(packet, "Noise control mode packet written: ");
|
||||||
|
}
|
||||||
|
void setNoiseControlMode(int mode)
|
||||||
|
{
|
||||||
|
setNoiseControlMode(static_cast<NoiseControlMode>(mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setConversationalAwareness(bool enabled) {
|
void setConversationalAwareness(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_conversationalAwareness == enabled)
|
||||||
|
{
|
||||||
|
LOG_INFO("Conversational awareness is already " << (enabled ? "enabled" : "disabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
LOG_INFO("Setting conversational awareness to: " << (enabled ? "enabled" : "disabled"));
|
LOG_INFO("Setting conversational awareness to: " << (enabled ? "enabled" : "disabled"));
|
||||||
QByteArray packet = enabled ? QByteArray::fromHex("0400040009002801000000") : QByteArray::fromHex("0400040009002802000000");
|
QByteArray packet = enabled ? AirPodsPackets::ConversationalAwareness::ENABLED
|
||||||
if (socket && socket->isOpen()) {
|
: AirPodsPackets::ConversationalAwareness::DISABLED;
|
||||||
|
|
||||||
|
writePacketToSocket(packet, "Conversational awareness packet written: ");
|
||||||
|
m_conversationalAwareness = enabled;
|
||||||
|
emit conversationalAwarenessChanged(enabled);
|
||||||
|
saveConversationalAwarenessState();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool writePacketToSocket(const QByteArray &packet, const QString &logMessage)
|
||||||
|
{
|
||||||
|
if (socket && socket->isOpen())
|
||||||
|
{
|
||||||
socket->write(packet);
|
socket->write(packet);
|
||||||
LOG_DEBUG("Conversational awareness packet written: " << packet.toHex());
|
LOG_DEBUG(logMessage << packet.toHex());
|
||||||
} else {
|
return true;
|
||||||
LOG_ERROR("Socket is not open, cannot write conversational awareness packet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateNoiseControlMenu(NoiseControlMode mode) {
|
|
||||||
QList<QAction *> actions = trayMenu->actions();
|
|
||||||
for (QAction *action : actions) {
|
|
||||||
action->setChecked(action->data().toInt() == mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateBatteryTooltip(const QString &status) {
|
|
||||||
trayIcon->setToolTip("Battery Status: " + status);
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateTrayIcon(const QString &status) {
|
|
||||||
QStringList parts = status.split(", ");
|
|
||||||
int leftLevel = parts[0].split(": ")[1].replace("%", "").toInt();
|
|
||||||
int rightLevel = parts[1].split(": ")[1].replace("%", "").toInt();
|
|
||||||
|
|
||||||
int minLevel;
|
|
||||||
if (leftLevel == 0)
|
|
||||||
{
|
|
||||||
minLevel = rightLevel;
|
|
||||||
}
|
|
||||||
else if (rightLevel == 0)
|
|
||||||
{
|
|
||||||
minLevel = leftLevel;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
minLevel = qMin(leftLevel, rightLevel);
|
LOG_ERROR("Socket is not open, cannot write packet");
|
||||||
}
|
return false;
|
||||||
|
|
||||||
|
|
||||||
QPixmap pixmap(32, 32);
|
|
||||||
pixmap.fill(Qt::transparent);
|
|
||||||
|
|
||||||
QPainter painter(&pixmap);
|
|
||||||
QColor textColor = QApplication::palette().color(QPalette::WindowText);
|
|
||||||
painter.setPen(textColor);
|
|
||||||
painter.setFont(QFont("Arial", 12, QFont::Bold));
|
|
||||||
painter.drawText(pixmap.rect(), Qt::AlignCenter, QString::number(minLevel) + "%");
|
|
||||||
painter.end();
|
|
||||||
|
|
||||||
trayIcon->setIcon(QIcon(pixmap));
|
|
||||||
}
|
|
||||||
|
|
||||||
void handleEarDetection(const QString &status) {
|
|
||||||
static bool wasPausedByApp = false;
|
|
||||||
|
|
||||||
QStringList parts = status.split(", ");
|
|
||||||
bool primaryInEar = parts[0].contains("In Ear");
|
|
||||||
bool secondaryInEar = parts[1].contains("In Ear");
|
|
||||||
|
|
||||||
LOG_DEBUG("Ear detection status: primaryInEar=" << primaryInEar << ", secondaryInEar=" << secondaryInEar << isActiveOutputDeviceAirPods());
|
|
||||||
if (primaryInEar || secondaryInEar) {
|
|
||||||
LOG_INFO("At least one AirPod is in ear");
|
|
||||||
activateA2dpProfile();
|
|
||||||
} else {
|
|
||||||
LOG_INFO("Both AirPods are out of ear");
|
|
||||||
removeAudioOutputDevice();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (primaryInEar && secondaryInEar) {
|
|
||||||
if (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 {
|
|
||||||
if (isActiveOutputDeviceAirPods()) {
|
|
||||||
QProcess process;
|
|
||||||
process.start("playerctl", QStringList() << "status");
|
|
||||||
process.waitForFinished();
|
|
||||||
QString playbackStatus = process.readAllStandardOutput().trimmed();
|
|
||||||
LOG_DEBUG("Playback status: " << playbackStatus);
|
|
||||||
if (playbackStatus == "Playing") {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void activateA2dpProfile() {
|
bool loadConversationalAwarenessState()
|
||||||
LOG_INFO("Activating A2DP profile for AirPods");
|
{
|
||||||
int result = QProcess::execute("pactl", QStringList() << "set-card-profile" << "bluez_card." + connectedDeviceMacAddress.replace(":", "_") << "a2dp-sink");
|
QSettings settings;
|
||||||
if (result != 0) {
|
return settings.value("conversationalAwareness", false).toBool();
|
||||||
LOG_ERROR("Failed to activate A2DP profile");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeAudioOutputDevice() {
|
|
||||||
LOG_INFO("Removing AirPods as audio output device");
|
|
||||||
int result = QProcess::execute("pactl", QStringList() << "set-card-profile" << "bluez_card." + connectedDeviceMacAddress.replace(":", "_") << "off");
|
|
||||||
if (result != 0) {
|
|
||||||
LOG_ERROR("Failed to remove AirPods as audio output device");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool loadConversationalAwarenessState() {
|
void saveConversationalAwarenessState()
|
||||||
QFile file(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ca_state.txt");
|
{
|
||||||
if (file.open(QIODevice::ReadOnly)) {
|
QSettings settings;
|
||||||
QTextStream in(&file);
|
settings.setValue("conversationalAwareness", m_conversationalAwareness);
|
||||||
QString state = in.readLine();
|
settings.sync();
|
||||||
file.close();
|
|
||||||
return state == "true";
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void saveConversationalAwarenessState(bool state) {
|
private slots:
|
||||||
QFile file(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ca_state.txt");
|
void onTrayIconActivated()
|
||||||
if (file.open(QIODevice::WriteOnly)) {
|
{
|
||||||
QTextStream out(&file);
|
QQuickWindow *window = qobject_cast<QQuickWindow *>(
|
||||||
out << (state ? "true" : "false");
|
QGuiApplication::topLevelWindows().constFirst());
|
||||||
file.close();
|
if (window)
|
||||||
}
|
{
|
||||||
}
|
window->show();
|
||||||
private slots:
|
window->raise();
|
||||||
void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason) {
|
window->requestActivate();
|
||||||
if (reason == QSystemTrayIcon::Trigger) {
|
|
||||||
LOG_INFO("Tray icon activated");
|
|
||||||
QQuickWindow *window = qobject_cast<QQuickWindow *>(
|
|
||||||
QGuiApplication::topLevelWindows().constFirst());
|
|
||||||
if (window) {
|
|
||||||
window->show();
|
|
||||||
window->raise();
|
|
||||||
window->requestActivate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,17 +335,19 @@ public slots:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onDeviceDisconnected(const QBluetoothAddress &address) {
|
void onDeviceDisconnected(const QBluetoothAddress &address)
|
||||||
|
{
|
||||||
LOG_INFO("Device disconnected: " << address.toString());
|
LOG_INFO("Device disconnected: " << address.toString());
|
||||||
if (socket) {
|
if (socket)
|
||||||
|
{
|
||||||
LOG_WARN("Socket is still open, closing it");
|
LOG_WARN("Socket is still open, closing it");
|
||||||
socket->close();
|
socket->close();
|
||||||
socket = nullptr;
|
socket = nullptr;
|
||||||
}
|
}
|
||||||
if (phoneSocket && phoneSocket->isOpen()) {
|
if (phoneSocket && phoneSocket->isOpen())
|
||||||
QByteArray airpodsDisconnectedPacket = QByteArray::fromHex("00010000");
|
{
|
||||||
phoneSocket->write(airpodsDisconnectedPacket);
|
phoneSocket->write(AirPodsPackets::Connection::AIRPODS_DISCONNECTED);
|
||||||
LOG_DEBUG("AIRPODS_DISCONNECTED packet written: " << airpodsDisconnectedPacket.toHex());
|
LOG_DEBUG("AIRPODS_DISCONNECTED packet written: " << AirPodsPackets::Connection::AIRPODS_DISCONNECTED.toHex());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,9 +363,9 @@ public slots:
|
|||||||
LOG_INFO("Connected to device, sending initial packets");
|
LOG_INFO("Connected to device, sending initial packets");
|
||||||
discoveryAgent->stop();
|
discoveryAgent->stop();
|
||||||
|
|
||||||
QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
|
QByteArray handshakePacket = AirPodsPackets::Connection::HANDSHAKE;
|
||||||
QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
|
QByteArray setSpecificFeaturesPacket = AirPodsPackets::Connection::SET_SPECIFIC_FEATURES;
|
||||||
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
|
QByteArray requestNotificationsPacket = AirPodsPackets::Connection::REQUEST_NOTIFICATIONS;
|
||||||
|
|
||||||
qint64 bytesWritten = localSocket->write(handshakePacket);
|
qint64 bytesWritten = localSocket->write(handshakePacket);
|
||||||
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
|
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
|
||||||
@@ -548,6 +417,7 @@ public slots:
|
|||||||
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
||||||
socket = localSocket;
|
socket = localSocket;
|
||||||
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
|
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
|
||||||
|
mediaController->setConnectedDeviceMacAddress(connectedDeviceMacAddress);
|
||||||
notifyAndroidDevice();
|
notifyAndroidDevice();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,114 +427,60 @@ public slots:
|
|||||||
: "In case";
|
: "In case";
|
||||||
}
|
}
|
||||||
|
|
||||||
void parseData(const QByteArray &data) {
|
void parseData(const QByteArray &data)
|
||||||
|
{
|
||||||
LOG_DEBUG("Received: " << data.toHex());
|
LOG_DEBUG("Received: " << data.toHex());
|
||||||
if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) {
|
|
||||||
quint8 rawMode = data[7] - 1;
|
// Noise Control Mode
|
||||||
if (rawMode >= NoiseControlMode::MinValue && rawMode <= NoiseControlMode::MaxValue)
|
if (data.size() == 11 && data.startsWith(AirPodsPackets::NoiseControl::HEADER))
|
||||||
|
{
|
||||||
|
quint8 rawMode = data[7] - 1; // Offset still needed due to protocol
|
||||||
|
if (rawMode >= (int)NoiseControlMode::MinValue && rawMode <= (int)NoiseControlMode::MaxValue)
|
||||||
{
|
{
|
||||||
NoiseControlMode mode = static_cast<NoiseControlMode>(rawMode);
|
m_noiseControlMode = static_cast<NoiseControlMode>(rawMode);
|
||||||
LOG_INFO("Noise control mode: " << rawMode);
|
LOG_INFO("Noise control mode: " << rawMode);
|
||||||
emit noiseControlModeChanged(mode);
|
emit noiseControlModeChanged(m_noiseControlMode);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LOG_ERROR("Invalid noise control mode value received: " << rawMode);
|
LOG_ERROR("Invalid noise control mode value received: " << rawMode);
|
||||||
}
|
}
|
||||||
} else if (data.size() == 8 && data.startsWith(QByteArray::fromHex("040004000600"))) {
|
}
|
||||||
|
// Ear Detection
|
||||||
|
else if (data.size() == 8 && data.startsWith(AirPodsPackets::Parse::EAR_DETECTION))
|
||||||
|
{
|
||||||
char primary = data[6];
|
char primary = data[6];
|
||||||
char secondary = data[7];
|
char secondary = data[7];
|
||||||
QString earDetectionStatus = QString("Primary: %1, Secondary: %2")
|
m_earDetectionStatus = QString("Primary: %1, Secondary: %2")
|
||||||
.arg(getEarStatus(primary), getEarStatus(secondary));
|
.arg(getEarStatus(primary), getEarStatus(secondary));
|
||||||
LOG_INFO("Ear detection status: " << earDetectionStatus);
|
LOG_INFO("Ear detection status: " << m_earDetectionStatus);
|
||||||
emit earDetectionStatusChanged(earDetectionStatus);
|
emit earDetectionStatusChanged(m_earDetectionStatus);
|
||||||
} else if (data.size() == 22 && data.startsWith(QByteArray::fromHex("040004000400"))) {
|
}
|
||||||
|
// Battery Status
|
||||||
|
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
|
||||||
|
{
|
||||||
int leftLevel = data[9];
|
int leftLevel = data[9];
|
||||||
int rightLevel = data[14];
|
int rightLevel = data[14];
|
||||||
int caseLevel = data[19];
|
int caseLevel = data[19];
|
||||||
QString batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%")
|
m_batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%")
|
||||||
.arg(leftLevel)
|
.arg(leftLevel)
|
||||||
.arg(rightLevel)
|
.arg(rightLevel)
|
||||||
.arg(caseLevel);
|
.arg(caseLevel);
|
||||||
LOG_INFO("Battery status: " << batteryStatus);
|
LOG_INFO("Battery status: " << m_batteryStatus);
|
||||||
emit batteryStatusChanged(batteryStatus);
|
emit batteryStatusChanged(m_batteryStatus);
|
||||||
|
}
|
||||||
} else if (data.size() == 10 && data.startsWith(QByteArray::fromHex("040004004B00020001"))) {
|
// Conversational Awareness Data
|
||||||
|
else if (data.size() == 10 && data.startsWith(AirPodsPackets::ConversationalAwareness::DATA_HEADER))
|
||||||
|
{
|
||||||
LOG_INFO("Received conversational awareness data");
|
LOG_INFO("Received conversational awareness data");
|
||||||
handleConversationalAwareness(data);
|
mediaController->handleConversationalAwareness(data);
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
|
{
|
||||||
void handleConversationalAwareness(const QByteArray &data) {
|
LOG_DEBUG("Unrecognized packet format: " << data.toHex());
|
||||||
LOG_DEBUG("Handling conversational awareness data: " << data.toHex());
|
|
||||||
static int initialVolume = -1;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool 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.replace(":", "_"));
|
|
||||||
}
|
|
||||||
|
|
||||||
void initializeMprisInterface() {
|
|
||||||
QStringList services = QDBusConnection::sessionBus().interface()->registeredServiceNames();
|
|
||||||
QString mprisService;
|
|
||||||
|
|
||||||
foreach (const QString &service, services) {
|
|
||||||
if (service.startsWith("org.mpris.MediaPlayer2.") && service != "org.mpris.MediaPlayer2") {
|
|
||||||
mprisService = service;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mprisService.isEmpty()) {
|
|
||||||
mprisInterface = new QDBusInterface(mprisService,
|
|
||||||
"/org/mpris/MediaPlayer2",
|
|
||||||
"org.mpris.MediaPlayer2.Player",
|
|
||||||
QDBusConnection::sessionBus(),
|
|
||||||
this);
|
|
||||||
if (!mprisInterface->isValid()) {
|
|
||||||
LOG_ERROR("Failed to initialize MPRIS interface for service: " << mprisService);
|
|
||||||
} else {
|
|
||||||
LOG_INFO("Connected to MPRIS service: " << mprisService);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOG_WARN("No active MPRIS media players found");
|
|
||||||
}
|
|
||||||
connectToPhone();
|
|
||||||
}
|
|
||||||
|
|
||||||
void connectToPhone() {
|
void connectToPhone() {
|
||||||
if (phoneSocket && phoneSocket->isOpen()) {
|
if (phoneSocket && phoneSocket->isOpen()) {
|
||||||
LOG_INFO("Already connected to the phone");
|
LOG_INFO("Already connected to the phone");
|
||||||
@@ -692,18 +508,22 @@ public slots:
|
|||||||
phoneSocket->connectToService(phoneAddress, QBluetoothUuid("1abbb9a4-10e4-4000-a75c-8953c5471342"));
|
phoneSocket->connectToService(phoneAddress, QBluetoothUuid("1abbb9a4-10e4-4000-a75c-8953c5471342"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void relayPacketToPhone(const QByteArray &packet) {
|
void relayPacketToPhone(const QByteArray &packet)
|
||||||
if (phoneSocket && phoneSocket->isOpen()) {
|
{
|
||||||
QByteArray header = QByteArray::fromHex("00040001");
|
if (phoneSocket && phoneSocket->isOpen())
|
||||||
phoneSocket->write(header + packet);
|
{
|
||||||
} else {
|
phoneSocket->write(AirPodsPackets::Phone::NOTIFICATION + packet);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
connectToPhone();
|
connectToPhone();
|
||||||
LOG_WARN("Phone socket is not open, cannot relay packet");
|
LOG_WARN("Phone socket is not open, cannot relay packet");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void handlePhonePacket(const QByteArray &packet) {
|
void handlePhonePacket(const QByteArray &packet) {
|
||||||
if (packet.startsWith(QByteArray::fromHex("00040001"))) {
|
if (packet.startsWith(AirPodsPackets::Phone::NOTIFICATION))
|
||||||
|
{
|
||||||
QByteArray airpodsPacket = packet.mid(4);
|
QByteArray airpodsPacket = packet.mid(4);
|
||||||
if (socket && socket->isOpen()) {
|
if (socket && socket->isOpen()) {
|
||||||
socket->write(airpodsPacket);
|
socket->write(airpodsPacket);
|
||||||
@@ -711,20 +531,29 @@ public slots:
|
|||||||
} else {
|
} else {
|
||||||
LOG_ERROR("Socket is not open, cannot relay packet to AirPods");
|
LOG_ERROR("Socket is not open, cannot relay packet to AirPods");
|
||||||
}
|
}
|
||||||
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
|
}
|
||||||
|
else if (packet.startsWith(AirPodsPackets::Phone::CONNECTED))
|
||||||
|
{
|
||||||
LOG_INFO("AirPods connected");
|
LOG_INFO("AirPods connected");
|
||||||
isConnectedLocally = true;
|
isConnectedLocally = true;
|
||||||
CrossDevice.isAvailable = false;
|
CrossDevice.isAvailable = false;
|
||||||
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
|
}
|
||||||
|
else if (packet.startsWith(AirPodsPackets::Phone::DISCONNECTED))
|
||||||
|
{
|
||||||
LOG_INFO("AirPods disconnected");
|
LOG_INFO("AirPods disconnected");
|
||||||
isConnectedLocally = false;
|
isConnectedLocally = false;
|
||||||
CrossDevice.isAvailable = true;
|
CrossDevice.isAvailable = true;
|
||||||
} else if (packet.startsWith(QByteArray::fromHex("00020003"))) {
|
}
|
||||||
|
else if (packet.startsWith(AirPodsPackets::Phone::STATUS_REQUEST))
|
||||||
|
{
|
||||||
LOG_INFO("Connection status request received");
|
LOG_INFO("Connection status request received");
|
||||||
QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000");
|
QByteArray response = (socket && socket->isOpen()) ? AirPodsPackets::Phone::CONNECTED
|
||||||
|
: AirPodsPackets::Phone::DISCONNECTED;
|
||||||
phoneSocket->write(response);
|
phoneSocket->write(response);
|
||||||
LOG_DEBUG("Sent connection status response: " << response.toHex());
|
LOG_DEBUG("Sent connection status response: " << response.toHex());
|
||||||
} else if (packet.startsWith(QByteArray::fromHex("00020000"))) {
|
}
|
||||||
|
else if (packet.startsWith(AirPodsPackets::Phone::DISCONNECT_REQUEST))
|
||||||
|
{
|
||||||
LOG_INFO("Disconnect request received");
|
LOG_INFO("Disconnect request received");
|
||||||
if (socket && socket->isOpen()) {
|
if (socket && socket->isOpen()) {
|
||||||
socket->close();
|
socket->close();
|
||||||
@@ -737,7 +566,9 @@ public slots:
|
|||||||
isConnectedLocally = false;
|
isConnectedLocally = false;
|
||||||
CrossDevice.isAvailable = true;
|
CrossDevice.isAvailable = true;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
if (socket && socket->isOpen()) {
|
if (socket && socket->isOpen()) {
|
||||||
socket->write(packet);
|
socket->write(packet);
|
||||||
LOG_DEBUG("Relayed packet to AirPods: " << packet.toHex());
|
LOG_DEBUG("Relayed packet to AirPods: " << packet.toHex());
|
||||||
@@ -773,26 +604,24 @@ public slots:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public: void followMediaChanges() {
|
public:
|
||||||
QProcess *playerctlProcess = new QProcess(this);
|
void handleMediaStateChange(MediaController::MediaState state) {
|
||||||
connect(playerctlProcess, &QProcess::readyReadStandardOutput, this, [this, playerctlProcess]() {
|
if (state == MediaController::MediaState::Playing) {
|
||||||
QString output = playerctlProcess->readAllStandardOutput().trimmed();
|
|
||||||
LOG_DEBUG("Playerctl output: " << output);
|
|
||||||
if (output == "Playing" && isPhoneConnected()) {
|
|
||||||
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
|
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
|
||||||
sendDisconnectRequestToAndroid();
|
sendDisconnectRequestToAndroid();
|
||||||
connectToAirPods(true);
|
connectToAirPods(true);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
playerctlProcess->start("playerctl", QStringList() << "--follow" << "status");
|
|
||||||
}
|
|
||||||
|
|
||||||
void sendDisconnectRequestToAndroid() {
|
void sendDisconnectRequestToAndroid()
|
||||||
if (phoneSocket && phoneSocket->isOpen()) {
|
{
|
||||||
QByteArray disconnectRequest = QByteArray::fromHex("00020000");
|
if (phoneSocket && phoneSocket->isOpen())
|
||||||
phoneSocket->write(disconnectRequest);
|
{
|
||||||
LOG_DEBUG("Sent disconnect request to Android: " << disconnectRequest.toHex());
|
phoneSocket->write(AirPodsPackets::Phone::DISCONNECT_REQUEST);
|
||||||
} else {
|
LOG_DEBUG("Sent disconnect request to Android: " << AirPodsPackets::Phone::DISCONNECT_REQUEST.toHex());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
LOG_WARN("Phone socket is not open, cannot send disconnect request");
|
LOG_WARN("Phone socket is not open, cannot send disconnect request");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -802,6 +631,11 @@ public slots:
|
|||||||
}
|
}
|
||||||
|
|
||||||
void connectToAirPods(bool force) {
|
void connectToAirPods(bool force) {
|
||||||
|
if (socket && socket->isOpen()) {
|
||||||
|
LOG_INFO("Already connected to AirPods");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (force) {
|
if (force) {
|
||||||
LOG_INFO("Forcing connection to AirPods");
|
LOG_INFO("Forcing connection to AirPods");
|
||||||
QProcess process;
|
QProcess process;
|
||||||
@@ -863,6 +697,7 @@ signals:
|
|||||||
void noiseControlModeChanged(NoiseControlMode mode);
|
void noiseControlModeChanged(NoiseControlMode mode);
|
||||||
void earDetectionStatusChanged(const QString &status);
|
void earDetectionStatusChanged(const QString &status);
|
||||||
void batteryStatusChanged(const QString &status);
|
void batteryStatusChanged(const QString &status);
|
||||||
|
void conversationalAwarenessChanged(bool enabled);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QSystemTrayIcon *trayIcon;
|
QSystemTrayIcon *trayIcon;
|
||||||
@@ -874,6 +709,13 @@ private:
|
|||||||
QString connectedDeviceMacAddress;
|
QString connectedDeviceMacAddress;
|
||||||
QByteArray lastBatteryStatus;
|
QByteArray lastBatteryStatus;
|
||||||
QByteArray lastEarDetectionStatus;
|
QByteArray lastEarDetectionStatus;
|
||||||
|
MediaController* mediaController;
|
||||||
|
TrayIconManager *trayManager;
|
||||||
|
|
||||||
|
QString m_batteryStatus;
|
||||||
|
QString m_earDetectionStatus;
|
||||||
|
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
|
||||||
|
bool m_conversationalAwareness = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
int main(int argc, char *argv[]) {
|
int main(int argc, char *argv[]) {
|
||||||
@@ -892,49 +734,6 @@ int main(int argc, char *argv[]) {
|
|||||||
engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp);
|
engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp);
|
||||||
engine.loadFromModule("linux", "Main");
|
engine.loadFromModule("linux", "Main");
|
||||||
|
|
||||||
QObject::connect(&trayApp, &AirPodsTrayApp::noiseControlModeChanged, &engine, [&engine](int mode) {
|
|
||||||
QObject *rootObject = engine.rootObjects().constFirst();
|
|
||||||
|
|
||||||
if (rootObject) {
|
|
||||||
QObject *noiseControlMode = rootObject->findChild<QObject*>("noiseControlMode");
|
|
||||||
if (noiseControlMode) {
|
|
||||||
if (mode >= 0 && mode <= 3) {
|
|
||||||
QMetaObject::invokeMethod(noiseControlMode, "setCurrentIndex", Q_ARG(int, mode));
|
|
||||||
} else {
|
|
||||||
LOG_ERROR("Invalid mode value: " << mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOG_ERROR("Root object not found");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
QObject::connect(&trayApp, &AirPodsTrayApp::earDetectionStatusChanged, [&engine](const QString &status) {
|
|
||||||
QObject *rootObject = engine.rootObjects().first();
|
|
||||||
if (rootObject) {
|
|
||||||
QObject *earDetectionStatus = rootObject->findChild<QObject*>("earDetectionStatus");
|
|
||||||
if (earDetectionStatus) {
|
|
||||||
earDetectionStatus->setProperty("text", "Ear Detection Status: " + status);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOG_ERROR("Root object not found");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
QObject::connect(&trayApp, &AirPodsTrayApp::batteryStatusChanged, [&engine](const QString &status) {
|
|
||||||
QObject *rootObject = engine.rootObjects().first();
|
|
||||||
if (rootObject) {
|
|
||||||
QObject *batteryStatus = rootObject->findChild<QObject*>("batteryStatus");
|
|
||||||
if (batteryStatus) {
|
|
||||||
batteryStatus->setProperty("text", "Battery Status: " + status);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOG_ERROR("Root object not found");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
trayApp.followMediaChanges();
|
|
||||||
|
|
||||||
return app.exec();
|
return app.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,13 +28,6 @@
|
|||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
|
||||||
Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
|
|
||||||
|
|
||||||
#define LOG_INFO(msg) qCInfo(airpodsApp) << "\033[32m" << msg << "\033[0m"
|
|
||||||
#define LOG_WARN(msg) qCWarning(airpodsApp) << "\033[33m" << msg << "\033[0m"
|
|
||||||
#define LOG_ERROR(msg) qCCritical(airpodsApp) << "\033[31m" << msg << "\033[0m"
|
|
||||||
#define LOG_DEBUG(msg) qCDebug(airpodsApp) << "\033[34m" << msg << "\033[0m"
|
|
||||||
|
|
||||||
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
|
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
|
||||||
|
|
||||||
#define MANUFACTURER_ID 0x1234
|
#define MANUFACTURER_ID 0x1234
|
||||||
|
|||||||
194
linux/mediacontroller.cpp
Normal file
194
linux/mediacontroller.cpp
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#include "mediacontroller.h"
|
||||||
|
#include "logger.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QDBusConnection>
|
||||||
|
#include <QDBusConnectionInterface>
|
||||||
|
|
||||||
|
MediaController::MediaController(QObject *parent) : QObject(parent) {
|
||||||
|
// No additional initialization required here
|
||||||
|
}
|
||||||
|
|
||||||
|
void MediaController::initializeMprisInterface() {
|
||||||
|
QStringList services =
|
||||||
|
QDBusConnection::sessionBus().interface()->registeredServiceNames();
|
||||||
|
QString mprisService;
|
||||||
|
|
||||||
|
for (const QString &service : services) {
|
||||||
|
if (service.startsWith("org.mpris.MediaPlayer2.") &&
|
||||||
|
service != "org.mpris.MediaPlayer2") {
|
||||||
|
mprisService = service;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mprisService.isEmpty()) {
|
||||||
|
mprisInterface = new QDBusInterface(mprisService, "/org/mpris/MediaPlayer2",
|
||||||
|
"org.mpris.MediaPlayer2.Player",
|
||||||
|
QDBusConnection::sessionBus(), this);
|
||||||
|
if (!mprisInterface->isValid()) {
|
||||||
|
LOG_ERROR("Failed to initialize MPRIS interface for service: ") << mprisService;
|
||||||
|
} else {
|
||||||
|
LOG_INFO("Connected to MPRIS service: " << mprisService);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOG_WARN("No active MPRIS media players found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MediaController::handleEarDetection(const QString &status) {
|
||||||
|
QStringList parts = status.split(", ");
|
||||||
|
bool primaryInEar = parts[0].contains("In Ear");
|
||||||
|
bool secondaryInEar = parts[1].contains("In Ear");
|
||||||
|
|
||||||
|
LOG_DEBUG("Ear detection status: primaryInEar="
|
||||||
|
<< primaryInEar << ", secondaryInEar=" << secondaryInEar
|
||||||
|
<< ", isAirPodsActive=" << isActiveOutputDeviceAirPods());
|
||||||
|
if (primaryInEar || secondaryInEar) {
|
||||||
|
LOG_INFO("At least one AirPod is in ear");
|
||||||
|
activateA2dpProfile();
|
||||||
|
} else {
|
||||||
|
LOG_INFO("Both AirPods are out of ear");
|
||||||
|
removeAudioOutputDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryInEar && secondaryInEar) {
|
||||||
|
if (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 {
|
||||||
|
if (isActiveOutputDeviceAirPods()) {
|
||||||
|
QProcess process;
|
||||||
|
process.start("playerctl", QStringList() << "status");
|
||||||
|
process.waitForFinished();
|
||||||
|
QString playbackStatus = process.readAllStandardOutput().trimmed();
|
||||||
|
LOG_DEBUG("Playback status: " << playbackStatus);
|
||||||
|
if (playbackStatus == "Playing") {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MediaController::followMediaChanges() {
|
||||||
|
playerctlProcess = new QProcess(this);
|
||||||
|
connect(playerctlProcess, &QProcess::readyReadStandardOutput, this,
|
||||||
|
[this]() {
|
||||||
|
QString output =
|
||||||
|
playerctlProcess->readAllStandardOutput().trimmed();
|
||||||
|
LOG_DEBUG("Playerctl output: " << output);
|
||||||
|
MediaState state = mediaStateFromPlayerctlOutput(output);
|
||||||
|
emit mediaStateChanged(state);
|
||||||
|
});
|
||||||
|
playerctlProcess->start("playerctl", QStringList() << "--follow" << "status");
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
LOG_INFO("Activating A2DP profile for AirPods");
|
||||||
|
int result = QProcess::execute(
|
||||||
|
"pactl", QStringList()
|
||||||
|
<< "set-card-profile"
|
||||||
|
<< "bluez_card." + connectedDeviceMacAddress << "a2dp-sink");
|
||||||
|
if (result != 0) {
|
||||||
|
LOG_ERROR("Failed to activate A2DP profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MediaController::removeAudioOutputDevice() {
|
||||||
|
LOG_INFO("Removing AirPods as audio output device");
|
||||||
|
int result = QProcess::execute(
|
||||||
|
"pactl", QStringList()
|
||||||
|
<< "set-card-profile"
|
||||||
|
<< "bluez_card." + connectedDeviceMacAddress << "off");
|
||||||
|
if (result != 0) {
|
||||||
|
LOG_ERROR("Failed to remove AirPods as audio output device");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) {
|
||||||
|
connectedDeviceMacAddress = macAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaController::MediaState MediaController::mediaStateFromPlayerctlOutput(
|
||||||
|
const QString &output) {
|
||||||
|
if (output == "Playing") {
|
||||||
|
return MediaState::Playing;
|
||||||
|
} else if (output == "Paused") {
|
||||||
|
return MediaState::Paused;
|
||||||
|
} else {
|
||||||
|
return MediaState::Stopped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaController::~MediaController() {
|
||||||
|
if (playerctlProcess) {
|
||||||
|
playerctlProcess->terminate();
|
||||||
|
if (!playerctlProcess->waitForFinished()) {
|
||||||
|
playerctlProcess->kill();
|
||||||
|
playerctlProcess->waitForFinished(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
linux/mediacontroller.h
Normal file
39
linux/mediacontroller.h
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#ifndef MEDIACONTROLLER_H
|
||||||
|
#define MEDIACONTROLLER_H
|
||||||
|
|
||||||
|
#include <QDBusInterface>
|
||||||
|
#include <QObject>
|
||||||
|
|
||||||
|
class QProcess;
|
||||||
|
|
||||||
|
class MediaController : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
enum MediaState { Playing, Paused, Stopped };
|
||||||
|
|
||||||
|
explicit MediaController(QObject *parent = nullptr);
|
||||||
|
~MediaController();
|
||||||
|
|
||||||
|
void initializeMprisInterface();
|
||||||
|
void handleEarDetection(const QString &status);
|
||||||
|
void followMediaChanges();
|
||||||
|
bool isActiveOutputDeviceAirPods();
|
||||||
|
void handleConversationalAwareness(const QByteArray &data);
|
||||||
|
void activateA2dpProfile();
|
||||||
|
void removeAudioOutputDevice();
|
||||||
|
void setConnectedDeviceMacAddress(const QString &macAddress);
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void mediaStateChanged(MediaState state);
|
||||||
|
|
||||||
|
private:
|
||||||
|
MediaState mediaStateFromPlayerctlOutput(const QString &output);
|
||||||
|
|
||||||
|
QDBusInterface *mprisInterface = nullptr;
|
||||||
|
QProcess *playerctlProcess = nullptr;
|
||||||
|
bool wasPausedByApp = false;
|
||||||
|
int initialVolume = -1;
|
||||||
|
QString connectedDeviceMacAddress;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // MEDIACONTROLLER_H
|
||||||
110
linux/trayiconmanager.cpp
Normal file
110
linux/trayiconmanager.cpp
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#include "trayiconmanager.h"
|
||||||
|
|
||||||
|
#include <QSystemTrayIcon>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QAction>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QActionGroup>
|
||||||
|
|
||||||
|
using namespace AirpodsTrayApp::Enums;
|
||||||
|
|
||||||
|
TrayIconManager::TrayIconManager(QObject *parent) : QObject(parent)
|
||||||
|
{
|
||||||
|
// Initialize tray icon
|
||||||
|
trayIcon = new QSystemTrayIcon(QIcon(":/icons/assets/airpods.png"), this);
|
||||||
|
trayMenu = new QMenu();
|
||||||
|
|
||||||
|
// Setup basic menu actions
|
||||||
|
setupMenuActions();
|
||||||
|
|
||||||
|
// Connect signals
|
||||||
|
trayIcon->setContextMenu(trayMenu);
|
||||||
|
connect(trayIcon, &QSystemTrayIcon::activated, this, &TrayIconManager::onTrayIconActivated);
|
||||||
|
|
||||||
|
trayIcon->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrayIconManager::TrayIconManager::updateBatteryStatus(const QString &status)
|
||||||
|
{
|
||||||
|
trayIcon->setToolTip("Battery Status: " + status);
|
||||||
|
updateIconFromBattery(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrayIconManager::updateNoiseControlState(NoiseControlMode mode)
|
||||||
|
{
|
||||||
|
QList<QAction *> actions = noiseControlGroup->actions();
|
||||||
|
for (QAction *action : actions)
|
||||||
|
{
|
||||||
|
action->setChecked(action->data().toInt() == (int)mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrayIconManager::updateConversationalAwareness(bool enabled)
|
||||||
|
{
|
||||||
|
caToggleAction->setChecked(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrayIconManager::setupMenuActions()
|
||||||
|
{
|
||||||
|
// Conversational Awareness Toggle
|
||||||
|
caToggleAction = new QAction("Toggle Conversational Awareness", trayMenu);
|
||||||
|
caToggleAction->setCheckable(true);
|
||||||
|
trayMenu->addAction(caToggleAction);
|
||||||
|
connect(caToggleAction, &QAction::triggered, this, [this](bool checked)
|
||||||
|
{ emit conversationalAwarenessToggled(checked); });
|
||||||
|
|
||||||
|
// Noise Control Options
|
||||||
|
noiseControlGroup = new QActionGroup(trayMenu);
|
||||||
|
const QPair<QString, NoiseControlMode> noiseOptions[] = {
|
||||||
|
{"Adaptive", NoiseControlMode::Adaptive},
|
||||||
|
{"Transparency", NoiseControlMode::Transparency},
|
||||||
|
{"Noise Cancellation", NoiseControlMode::NoiseCancellation},
|
||||||
|
{"Off", NoiseControlMode::Off}};
|
||||||
|
|
||||||
|
for (auto option : noiseOptions)
|
||||||
|
{
|
||||||
|
QAction *action = new QAction(option.first, trayMenu);
|
||||||
|
action->setCheckable(true);
|
||||||
|
action->setData((int)option.second);
|
||||||
|
noiseControlGroup->addAction(action);
|
||||||
|
trayMenu->addAction(action);
|
||||||
|
connect(action, &QAction::triggered, this, [this, mode = option.second]()
|
||||||
|
{ emit noiseControlChanged(mode); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quit action
|
||||||
|
QAction *quitAction = new QAction("Quit", trayMenu);
|
||||||
|
trayMenu->addAction(quitAction);
|
||||||
|
connect(quitAction, &QAction::triggered, qApp, &QApplication::quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrayIconManager::updateIconFromBattery(const QString &status)
|
||||||
|
{
|
||||||
|
QStringList parts = status.split(", ");
|
||||||
|
int leftLevel = parts[0].split(": ")[1].replace("%", "").toInt();
|
||||||
|
int rightLevel = parts[1].split(": ")[1].replace("%", "").toInt();
|
||||||
|
|
||||||
|
int minLevel = (leftLevel == 0) ? rightLevel : (rightLevel == 0) ? leftLevel
|
||||||
|
: qMin(leftLevel, rightLevel);
|
||||||
|
|
||||||
|
QPixmap pixmap(32, 32);
|
||||||
|
pixmap.fill(Qt::transparent);
|
||||||
|
QPainter painter(&pixmap);
|
||||||
|
painter.setPen(QApplication::palette().color(QPalette::WindowText));
|
||||||
|
painter.setFont(QFont("Arial", 12, QFont::Bold));
|
||||||
|
painter.drawText(pixmap.rect(), Qt::AlignCenter, QString::number(minLevel) + "%");
|
||||||
|
painter.end();
|
||||||
|
|
||||||
|
trayIcon->setIcon(QIcon(pixmap));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrayIconManager::onTrayIconActivated(QSystemTrayIcon::ActivationReason reason)
|
||||||
|
{
|
||||||
|
if (reason == QSystemTrayIcon::Trigger)
|
||||||
|
{
|
||||||
|
emit trayClicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
40
linux/trayiconmanager.h
Normal file
40
linux/trayiconmanager.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#include <QObject>
|
||||||
|
#include <QSystemTrayIcon>
|
||||||
|
|
||||||
|
#include "enums.h"
|
||||||
|
|
||||||
|
class QMenu;
|
||||||
|
class QAction;
|
||||||
|
class QActionGroup;
|
||||||
|
|
||||||
|
class TrayIconManager : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit TrayIconManager(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
void updateBatteryStatus(const QString &status);
|
||||||
|
|
||||||
|
void updateNoiseControlState(AirpodsTrayApp::Enums::NoiseControlMode);
|
||||||
|
|
||||||
|
void updateConversationalAwareness(bool enabled);
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QSystemTrayIcon *trayIcon;
|
||||||
|
QMenu *trayMenu;
|
||||||
|
QAction *caToggleAction;
|
||||||
|
QActionGroup *noiseControlGroup;
|
||||||
|
|
||||||
|
void setupMenuActions();
|
||||||
|
|
||||||
|
void updateIconFromBattery(const QString &status);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void trayClicked();
|
||||||
|
void noiseControlChanged(AirpodsTrayApp::Enums::NoiseControlMode);
|
||||||
|
void conversationalAwarenessToggled(bool enabled);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user