diff --git a/.gitignore b/.gitignore index 384a986..2d8d9c4 100644 --- a/.gitignore +++ b/.gitignore @@ -657,3 +657,4 @@ obj/ !/gradle/wrapper/gradle-wrapper.jar # End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux +linux/.qmlls.ini diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index eb764af..94555c7 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -4,12 +4,20 @@ project(linux VERSION 0.1 LANGUAGES CXX) 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_add_executable(applinux main.cpp + main.h + logger.h + mediacontroller.cpp + mediacontroller.h + airpods_packets.h + trayiconmanager.cpp + trayiconmanager.h + enums.h ) qt_add_qml_module(applinux @@ -19,8 +27,15 @@ qt_add_qml_module(applinux Main.qml ) +# Add the resource file +qt_add_resources(applinux "resources" + PREFIX "/icons" + FILES + assets/airpods.png +) + 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) diff --git a/linux/Main.qml b/linux/Main.qml index f5863b4..d980272 100644 --- a/linux/Main.qml +++ b/linux/Main.qml @@ -6,58 +6,35 @@ ApplicationWindow { width: 400 height: 300 title: "AirPods Settings" - property bool ignoreNoiseControlChange: false - property bool isPlaying: false - - Component.onCompleted: { - caToggle.checked = airPodsTrayApp.loadConversationalAwarenessState() - } Column { spacing: 20 padding: 20 Text { - text: "Battery Status: " id: batteryStatus - objectName: "batteryStatus" + text: "Battery Status: " + airPodsTrayApp.batteryStatus color: "#ffffff" } Text { - text: "Ear Detection Status: " id: earDetectionStatus - objectName: "earDetectionStatus" + text: "Ear Detection Status: " + airPodsTrayApp.earDetectionStatus color: "#ffffff" } ComboBox { id: noiseControlMode model: ["Off", "Noise Cancellation", "Transparency", "Adaptive"] - currentIndex: 0 - onCurrentIndexChanged: { - if (!ignoreNoiseControlChange) { - airPodsTrayApp.setNoiseControlMode(currentIndex) - } - } - Connections { - target: airPodsTrayApp - onNoiseControlModeChanged: { - ignoreNoiseControlChange = true - noiseControlMode.currentIndex = mode; - ignoreNoiseControlChange = false - } - } + currentIndex: airPodsTrayApp.noiseControlMode + onCurrentIndexChanged: airPodsTrayApp.noiseControlMode = currentIndex } Switch { id: caToggle text: "Conversational Awareness" - checked: isPlaying - onCheckedChanged: { - airPodsTrayApp.setConversationalAwareness(checked) - airPodsTrayApp.saveConversationalAwarenessState(checked) - } + checked: airPodsTrayApp.conversationalAwareness + onCheckedChanged: airPodsTrayApp.conversationalAwareness = checked } } -} +} \ No newline at end of file diff --git a/linux/airpods_packets.h b/linux/airpods_packets.h new file mode 100644 index 0000000..718555c --- /dev/null +++ b/linux/airpods_packets.h @@ -0,0 +1,55 @@ +// airpods_packets.h +#ifndef AIRPODS_PACKETS_H +#define AIRPODS_PACKETS_H + +#include + +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 \ No newline at end of file diff --git a/linux/assets/airpods.png b/linux/assets/airpods.png new file mode 100644 index 0000000..861ddf7 Binary files /dev/null and b/linux/assets/airpods.png differ diff --git a/linux/enums.h b/linux/enums.h new file mode 100644 index 0000000..9a94d96 --- /dev/null +++ b/linux/enums.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +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) + } +} \ No newline at end of file diff --git a/linux/logger.h b/linux/logger.h new file mode 100644 index 0000000..dddb9d5 --- /dev/null +++ b/linux/logger.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +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" diff --git a/linux/main.cpp b/linux/main.cpp index 0f8022d..74ebb1c 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -1,31 +1,24 @@ +#include + #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" -#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" +using namespace AirpodsTrayApp::Enums; -#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0" - -#define MANUFACTURER_ID 0x1234 -#define MANUFACTURER_DATA "ALN_AirPods" +Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp") class AirPodsTrayApp : public QObject { 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: - enum NoiseControlMode : quint8 - { - Off = 0, - NoiseCancellation = 1, - Transparency = 2, - Adaptive = 3, - - MinValue = Off, - MaxValue = Adaptive, - }; - Q_ENUM(NoiseControlMode) - AirPodsTrayApp(bool debugMode) : debugMode(debugMode) { if (debugMode) { QLoggingCategory::setFilterRules("airpodsApp.debug=true"); @@ -33,65 +26,25 @@ public: QLoggingCategory::setFilterRules("airpodsApp.debug=false"); } LOG_INFO("Initializing AirPodsTrayApp"); - trayIcon = new QSystemTrayIcon(QIcon(":/icons/airpods.png")); - trayMenu = new QMenu(); - bool caState = loadConversationalAwarenessState(); - QAction *caToggleAction = new QAction("Toggle Conversational Awareness", trayMenu); - caToggleAction->setCheckable(true); - caToggleAction->setChecked(caState); - connect(caToggleAction, &QAction::triggered, this, [this, caToggleAction]() { - bool newState = !caToggleAction->isChecked(); - setConversationalAwareness(newState); - saveConversationalAwarenessState(newState); - caToggleAction->setChecked(newState); - }); - trayMenu->addAction(caToggleAction); + // Initialize tray icon and connect signals + trayManager = new TrayIconManager(this); + connect(trayManager, &TrayIconManager::trayClicked, this, &AirPodsTrayApp::onTrayIconActivated); + connect(trayManager, &TrayIconManager::noiseControlChanged, this, qOverload(&AirPodsTrayApp::setNoiseControlMode)); + connect(trayManager, &TrayIconManager::conversationalAwarenessToggled, this, &AirPodsTrayApp::setConversationalAwareness); + connect(this, &AirPodsTrayApp::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus); + connect(this, &AirPodsTrayApp::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState); + connect(this, &AirPodsTrayApp::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness); - QAction *offAction = new QAction("Off", trayMenu); - QAction *transparencyAction = new QAction("Transparency", trayMenu); - QAction *adaptiveAction = new QAction("Adaptive", trayMenu); - QAction *noiseCancellationAction = new QAction("Noise Cancellation", trayMenu); + // Initialize MediaController and connect signals + mediaController = new MediaController(this); + connect(this, &AirPodsTrayApp::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection); + connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange); + mediaController->initializeMprisInterface(); + mediaController->followMediaChanges(); - offAction->setData(NoiseControlMode::Off); - transparencyAction->setData(NoiseControlMode::Transparency); - 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); + // load conversational awareness state + setConversationalAwareness(loadConversationalAwarenessState()); discoveryAgent = new QBluetoothDeviceDiscoveryAgent(); discoveryAgent->setLowEnergyDiscoveryTimeout(15000); @@ -113,7 +66,6 @@ public: return; } } - initializeMprisInterface(); connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived); QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1"); @@ -139,6 +91,11 @@ public: delete phoneSocket; } + QString batteryStatus() const { return m_batteryStatus; } + QString earDetectionStatus() const { return m_earDetectionStatus; } + int noiseControlMode() const { return static_cast(m_noiseControlMode); } + bool conversationalAwareness() const { return m_conversationalAwareness; } + private: bool debugMode; bool isConnectedLocally = false; @@ -187,16 +144,18 @@ private: QDBusConnection::systemBus().registerService("me.kavishdevar.aln"); } - void notifyAndroidDevice() { - if (phoneSocket && phoneSocket->isOpen()) { - QByteArray notificationPacket = QByteArray::fromHex("00040001"); - phoneSocket->write(notificationPacket); - LOG_DEBUG("Sent notification packet to Android: " << notificationPacket.toHex()); - } else { + void notifyAndroidDevice() + { + if (phoneSocket && phoneSocket->isOpen()) + { + phoneSocket->write(AirPodsPackets::Phone::NOTIFICATION); + LOG_DEBUG("Sent notification packet to Android: " << AirPodsPackets::Phone::NOTIFICATION.toHex()); + } + else + { LOG_WARN("Phone socket is not open, cannot send notification packet"); } } - void onNameOwnerChanged(const QString &name, const QString &oldOwner, const QString &newOwner) { if (name == "org.bluez") { 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); QByteArray packet; - switch (mode) { - case Off: - packet = QByteArray::fromHex("0400040009000D01000000"); - break; - case NoiseCancellation: - packet = QByteArray::fromHex("0400040009000D02000000"); - break; - case Transparency: - packet = QByteArray::fromHex("0400040009000D03000000"); - break; - case Adaptive: - packet = QByteArray::fromHex("0400040009000D04000000"); - 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"); + switch (mode) + { + case NoiseControlMode::Off: + packet = AirPodsPackets::NoiseControl::OFF; + break; + case NoiseControlMode::NoiseCancellation: + packet = AirPodsPackets::NoiseControl::NOISE_CANCELLATION; + break; + case NoiseControlMode::Transparency: + packet = AirPodsPackets::NoiseControl::TRANSPARENCY; + break; + case NoiseControlMode::Adaptive: + packet = AirPodsPackets::NoiseControl::ADAPTIVE; + break; } + writePacketToSocket(packet, "Noise control mode packet written: "); + } + void setNoiseControlMode(int mode) + { + setNoiseControlMode(static_cast(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")); - QByteArray packet = enabled ? QByteArray::fromHex("0400040009002801000000") : QByteArray::fromHex("0400040009002802000000"); - if (socket && socket->isOpen()) { + QByteArray packet = enabled ? AirPodsPackets::ConversationalAwareness::ENABLED + : 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); - LOG_DEBUG("Conversational awareness packet written: " << packet.toHex()); - } else { - LOG_ERROR("Socket is not open, cannot write conversational awareness packet"); - } - } - - void updateNoiseControlMenu(NoiseControlMode mode) { - QList 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; + LOG_DEBUG(logMessage << packet.toHex()); + return true; } else { - minLevel = qMin(leftLevel, rightLevel); - } - - - 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"); - } - } - } + LOG_ERROR("Socket is not open, cannot write packet"); + return false; } } - void activateA2dpProfile() { - LOG_INFO("Activating A2DP profile for AirPods"); - int result = QProcess::execute("pactl", QStringList() << "set-card-profile" << "bluez_card." + connectedDeviceMacAddress.replace(":", "_") << "a2dp-sink"); - if (result != 0) { - 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() + { + QSettings settings; + return settings.value("conversationalAwareness", false).toBool(); } - bool loadConversationalAwarenessState() { - QFile file(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ca_state.txt"); - if (file.open(QIODevice::ReadOnly)) { - QTextStream in(&file); - QString state = in.readLine(); - file.close(); - return state == "true"; - } - return false; + void saveConversationalAwarenessState() + { + QSettings settings; + settings.setValue("conversationalAwareness", m_conversationalAwareness); + settings.sync(); } - void saveConversationalAwarenessState(bool state) { - QFile file(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ca_state.txt"); - if (file.open(QIODevice::WriteOnly)) { - QTextStream out(&file); - out << (state ? "true" : "false"); - file.close(); - } - } - private slots: - void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason) { - if (reason == QSystemTrayIcon::Trigger) { - LOG_INFO("Tray icon activated"); - QQuickWindow *window = qobject_cast( - QGuiApplication::topLevelWindows().constFirst()); - if (window) { - window->show(); - window->raise(); - window->requestActivate(); - } +private slots: + void onTrayIconActivated() + { + QQuickWindow *window = qobject_cast( + 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()); - if (socket) { + if (socket) + { LOG_WARN("Socket is still open, closing it"); socket->close(); socket = nullptr; } - if (phoneSocket && phoneSocket->isOpen()) { - QByteArray airpodsDisconnectedPacket = QByteArray::fromHex("00010000"); - phoneSocket->write(airpodsDisconnectedPacket); - LOG_DEBUG("AIRPODS_DISCONNECTED packet written: " << airpodsDisconnectedPacket.toHex()); + if (phoneSocket && phoneSocket->isOpen()) + { + phoneSocket->write(AirPodsPackets::Connection::AIRPODS_DISCONNECTED); + 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"); discoveryAgent->stop(); - QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000"); - QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000"); - QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff"); + QByteArray handshakePacket = AirPodsPackets::Connection::HANDSHAKE; + QByteArray setSpecificFeaturesPacket = AirPodsPackets::Connection::SET_SPECIFIC_FEATURES; + QByteArray requestNotificationsPacket = AirPodsPackets::Connection::REQUEST_NOTIFICATIONS; qint64 bytesWritten = localSocket->write(handshakePacket); 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")); socket = localSocket; connectedDeviceMacAddress = device.address().toString().replace(":", "_"); + mediaController->setConnectedDeviceMacAddress(connectedDeviceMacAddress); notifyAndroidDevice(); } @@ -557,114 +427,60 @@ public slots: : "In case"; } - void parseData(const QByteArray &data) { + void parseData(const QByteArray &data) + { LOG_DEBUG("Received: " << data.toHex()); - if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) { - quint8 rawMode = data[7] - 1; - if (rawMode >= NoiseControlMode::MinValue && rawMode <= NoiseControlMode::MaxValue) + + // Noise Control Mode + 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(rawMode); + m_noiseControlMode = static_cast(rawMode); LOG_INFO("Noise control mode: " << rawMode); - emit noiseControlModeChanged(mode); + emit noiseControlModeChanged(m_noiseControlMode); } else { 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 secondary = data[7]; - QString earDetectionStatus = QString("Primary: %1, Secondary: %2") - .arg(getEarStatus(primary), getEarStatus(secondary)); - LOG_INFO("Ear detection status: " << earDetectionStatus); - emit earDetectionStatusChanged(earDetectionStatus); - } else if (data.size() == 22 && data.startsWith(QByteArray::fromHex("040004000400"))) { + m_earDetectionStatus = QString("Primary: %1, Secondary: %2") + .arg(getEarStatus(primary), getEarStatus(secondary)); + LOG_INFO("Ear detection status: " << m_earDetectionStatus); + emit earDetectionStatusChanged(m_earDetectionStatus); + } + // Battery Status + else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS)) + { int leftLevel = data[9]; int rightLevel = data[14]; int caseLevel = data[19]; - QString batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%") - .arg(leftLevel) - .arg(rightLevel) - .arg(caseLevel); - LOG_INFO("Battery status: " << batteryStatus); - emit batteryStatusChanged(batteryStatus); - - } else if (data.size() == 10 && data.startsWith(QByteArray::fromHex("040004004B00020001"))) { + m_batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%") + .arg(leftLevel) + .arg(rightLevel) + .arg(caseLevel); + LOG_INFO("Battery status: " << m_batteryStatus); + emit batteryStatusChanged(m_batteryStatus); + } + // Conversational Awareness Data + else if (data.size() == 10 && data.startsWith(AirPodsPackets::ConversationalAwareness::DATA_HEADER)) + { LOG_INFO("Received conversational awareness data"); - handleConversationalAwareness(data); + mediaController->handleConversationalAwareness(data); } - } - - void handleConversationalAwareness(const QByteArray &data) { - 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; - } + else + { + LOG_DEBUG("Unrecognized packet format: " << data.toHex()); } } - 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() { if (phoneSocket && phoneSocket->isOpen()) { LOG_INFO("Already connected to the phone"); @@ -692,18 +508,22 @@ public slots: phoneSocket->connectToService(phoneAddress, QBluetoothUuid("1abbb9a4-10e4-4000-a75c-8953c5471342")); } - void relayPacketToPhone(const QByteArray &packet) { - if (phoneSocket && phoneSocket->isOpen()) { - QByteArray header = QByteArray::fromHex("00040001"); - phoneSocket->write(header + packet); - } else { + void relayPacketToPhone(const QByteArray &packet) + { + if (phoneSocket && phoneSocket->isOpen()) + { + phoneSocket->write(AirPodsPackets::Phone::NOTIFICATION + packet); + } + else + { connectToPhone(); LOG_WARN("Phone socket is not open, cannot relay packet"); } } void handlePhonePacket(const QByteArray &packet) { - if (packet.startsWith(QByteArray::fromHex("00040001"))) { + if (packet.startsWith(AirPodsPackets::Phone::NOTIFICATION)) + { QByteArray airpodsPacket = packet.mid(4); if (socket && socket->isOpen()) { socket->write(airpodsPacket); @@ -711,20 +531,29 @@ public slots: } else { 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"); isConnectedLocally = true; CrossDevice.isAvailable = false; - } else if (packet.startsWith(QByteArray::fromHex("00010000"))) { + } + else if (packet.startsWith(AirPodsPackets::Phone::DISCONNECTED)) + { LOG_INFO("AirPods disconnected"); isConnectedLocally = false; CrossDevice.isAvailable = true; - } else if (packet.startsWith(QByteArray::fromHex("00020003"))) { + } + else if (packet.startsWith(AirPodsPackets::Phone::STATUS_REQUEST)) + { 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); 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"); if (socket && socket->isOpen()) { socket->close(); @@ -737,7 +566,9 @@ public slots: isConnectedLocally = false; CrossDevice.isAvailable = true; } - } else { + } + else + { if (socket && socket->isOpen()) { socket->write(packet); LOG_DEBUG("Relayed packet to AirPods: " << packet.toHex()); @@ -773,26 +604,24 @@ public slots: } } - public: void followMediaChanges() { - QProcess *playerctlProcess = new QProcess(this); - connect(playerctlProcess, &QProcess::readyReadStandardOutput, this, [this, playerctlProcess]() { - QString output = playerctlProcess->readAllStandardOutput().trimmed(); - LOG_DEBUG("Playerctl output: " << output); - if (output == "Playing" && isPhoneConnected()) { + public: + void handleMediaStateChange(MediaController::MediaState state) { + if (state == MediaController::MediaState::Playing) { LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio"); sendDisconnectRequestToAndroid(); connectToAirPods(true); } - }); - playerctlProcess->start("playerctl", QStringList() << "--follow" << "status"); - } + } - void sendDisconnectRequestToAndroid() { - if (phoneSocket && phoneSocket->isOpen()) { - QByteArray disconnectRequest = QByteArray::fromHex("00020000"); - phoneSocket->write(disconnectRequest); - LOG_DEBUG("Sent disconnect request to Android: " << disconnectRequest.toHex()); - } else { + void sendDisconnectRequestToAndroid() + { + if (phoneSocket && phoneSocket->isOpen()) + { + phoneSocket->write(AirPodsPackets::Phone::DISCONNECT_REQUEST); + LOG_DEBUG("Sent disconnect request to Android: " << AirPodsPackets::Phone::DISCONNECT_REQUEST.toHex()); + } + else + { LOG_WARN("Phone socket is not open, cannot send disconnect request"); } } @@ -802,6 +631,11 @@ public slots: } void connectToAirPods(bool force) { + if (socket && socket->isOpen()) { + LOG_INFO("Already connected to AirPods"); + return; + } + if (force) { LOG_INFO("Forcing connection to AirPods"); QProcess process; @@ -863,6 +697,7 @@ signals: void noiseControlModeChanged(NoiseControlMode mode); void earDetectionStatusChanged(const QString &status); void batteryStatusChanged(const QString &status); + void conversationalAwarenessChanged(bool enabled); private: QSystemTrayIcon *trayIcon; @@ -874,6 +709,13 @@ private: QString connectedDeviceMacAddress; QByteArray lastBatteryStatus; 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[]) { @@ -892,49 +734,6 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp); engine.loadFromModule("linux", "Main"); - QObject::connect(&trayApp, &AirPodsTrayApp::noiseControlModeChanged, &engine, [&engine](int mode) { - QObject *rootObject = engine.rootObjects().constFirst(); - - if (rootObject) { - QObject *noiseControlMode = rootObject->findChild("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("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("batteryStatus"); - if (batteryStatus) { - batteryStatus->setProperty("text", "Battery Status: " + status); - } - } else { - LOG_ERROR("Root object not found"); - } - }); - - trayApp.followMediaChanges(); - return app.exec(); } diff --git a/linux/main.h b/linux/main.h index adfa9d2..8e212c5 100644 --- a/linux/main.h +++ b/linux/main.h @@ -28,13 +28,6 @@ #include #include -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 MANUFACTURER_ID 0x1234 diff --git a/linux/mediacontroller.cpp b/linux/mediacontroller.cpp new file mode 100644 index 0000000..44c51f2 --- /dev/null +++ b/linux/mediacontroller.cpp @@ -0,0 +1,194 @@ +#include "mediacontroller.h" +#include "logger.h" + +#include +#include +#include +#include +#include + +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); + } + } +} \ No newline at end of file diff --git a/linux/mediacontroller.h b/linux/mediacontroller.h new file mode 100644 index 0000000..32d57ad --- /dev/null +++ b/linux/mediacontroller.h @@ -0,0 +1,39 @@ +#ifndef MEDIACONTROLLER_H +#define MEDIACONTROLLER_H + +#include +#include + +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 diff --git a/linux/trayiconmanager.cpp b/linux/trayiconmanager.cpp new file mode 100644 index 0000000..629288e --- /dev/null +++ b/linux/trayiconmanager.cpp @@ -0,0 +1,110 @@ +#include "trayiconmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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(); + } +} \ No newline at end of file diff --git a/linux/trayiconmanager.h b/linux/trayiconmanager.h new file mode 100644 index 0000000..89c1003 --- /dev/null +++ b/linux/trayiconmanager.h @@ -0,0 +1,40 @@ +#include +#include + +#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); +}; \ No newline at end of file