diff --git a/linux/BatteryIndicator.qml b/linux/BatteryIndicator.qml index a968073..7e3864d 100644 --- a/linux/BatteryIndicator.qml +++ b/linux/BatteryIndicator.qml @@ -10,6 +10,7 @@ Rectangle { property int batteryLevel: 50 // 0-100 property bool isCharging: false property bool darkMode: false + property string indicator: "" // "L" or "R" // Private properties readonly property color darkModeBackground: "#1C1C1E" @@ -40,26 +41,16 @@ Rectangle { return batteryHighColor; } - RowLayout { + ColumnLayout { anchors.fill: parent - spacing: 5 - - // Battery percentage text - Text { - id: percentageText - text: root.batteryLevel + "%" - color: root.textColor - font.pixelSize: 14 - font.family: "SF Pro Text" // Apple system font - Layout.alignment: Qt.AlignVCenter - } + spacing: 7 // Battery icon Item { id: batteryIcon Layout.preferredWidth: 32 Layout.preferredHeight: 16 - Layout.alignment: Qt.AlignVCenter + Layout.alignment: Qt.AlignHCenter // Main battery body Rectangle { @@ -121,7 +112,7 @@ Rectangle { ctx.reset(); // Draw a lightning bolt - ctx.fillStyle = root.darkMode ? "#000000" : "#FFFFFF"; + ctx.fillStyle = root.darkMode ? "#FFFFFF" : "#000000"; ctx.beginPath(); ctx.moveTo(7, 2); // Top point ctx.lineTo(3, 8); // Middle left @@ -135,5 +126,39 @@ Rectangle { } } } + + // Text container + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 4 + + // Left/Right indicator + Rectangle { + id: indicatorBackground + visible: root.indicator !== "" + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + radius: width / 2 + color: root.darkMode ? "#FFFFFF" : "#1C1C1E" + + Text { + id: indicatorText + anchors.centerIn: parent + text: root.indicator + color: root.darkMode ? "#1C1C1E" : "#FFFFFF" + font.pixelSize: 10 + font.family: "SF Pro Text" + } + } + + // Battery percentage + Text { + id: percentageText + text: root.batteryLevel + "%" + color: root.textColor + font.pixelSize: 12 + font.family: "SF Pro Text" + } + } } } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 7dcb4dc..3b95781 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -34,6 +34,14 @@ qt_add_resources(applinux "resources" PREFIX "/icons" FILES assets/airpods.png + assets/pod.png + assets/pod_case.png + assets/pod3.png + assets/pod3_case.png + assets/pod4_case.png + assets/podpro.png + assets/podpro_case.png + assets/podmax.png ) target_link_libraries(applinux diff --git a/linux/Main.qml b/linux/Main.qml index a2da2c5..065a335 100644 --- a/linux/Main.qml +++ b/linux/Main.qml @@ -1,6 +1,5 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 -import me.kavishdevar.Battery 1.0 ApplicationWindow { visible: true @@ -9,6 +8,8 @@ ApplicationWindow { title: "AirPods Settings" Column { + anchors.left: parent.left + anchors.right: parent.right spacing: 20 padding: 20 @@ -16,52 +17,77 @@ ApplicationWindow { Row { // center the content anchors.horizontalCenter: parent.horizontalCenter - spacing: 15 + spacing: 8 Column { spacing: 5 + opacity: airPodsTrayApp.isLeftPodInEar ? 1 : 0.5 + visible: airPodsTrayApp.battery.leftPodAvailable - Text { - text: "Left" - color: "#ffffff" - font.pixelSize: 12 + Image { + source: "qrc:/icons/assets/" + airPodsTrayApp.podIcon + width: 72 + height: 72 + fillMode: Image.PreserveAspectFit + smooth: true + antialiasing: true + mipmap: true + anchors.horizontalCenter: parent.horizontalCenter } BatteryIndicator { + visible: airPodsTrayApp.leftPodAvailable batteryLevel: airPodsTrayApp.battery.leftPodLevel isCharging: airPodsTrayApp.battery.leftPodCharging darkMode: true + indicator: "L" } } Column { spacing: 5 + opacity: airPodsTrayApp.isRightPodInEar ? 1 : 0.5 + visible: airPodsTrayApp.battery.rightPodAvailable - Text { - text: "Right" - color: "#ffffff" - font.pixelSize: 12 + Image { + source: "qrc:/icons/assets/" + airPodsTrayApp.podIcon + mirror: true + width: 72 + height: 72 + fillMode: Image.PreserveAspectFit + smooth: true + antialiasing: true + mipmap: true + anchors.horizontalCenter: parent.horizontalCenter } BatteryIndicator { + visible: airPodsTrayApp.rightPodAvailable batteryLevel: airPodsTrayApp.battery.rightPodLevel isCharging: airPodsTrayApp.battery.rightPodCharging darkMode: true + indicator: "R" } } Column { spacing: 5 // hide the case status if battery level is 0 and no pod is in case - visible: airPodsTrayApp.battery.caseLevel > 0 || airPodsTrayApp.oneOrMorePodsInCase + visible: airPodsTrayApp.battery.caseAvailable - Text { - text: "Case" - color: "#ffffff" - font.pixelSize: 12 + Image { + source: "qrc:/icons/assets/" + airPodsTrayApp.caseIcon + width: 92 + height: 72 + fillMode: Image.PreserveAspectFit + smooth: true + antialiasing: true + mipmap: true + anchors.horizontalCenter: parent.horizontalCenter } BatteryIndicator { + visible: airPodsTrayApp.caseAvailable batteryLevel: airPodsTrayApp.battery.caseLevel isCharging: airPodsTrayApp.battery.caseCharging darkMode: true @@ -138,7 +164,6 @@ ApplicationWindow { text: "Rename" onClicked: { airPodsTrayApp.renameAirPods(newNameField.text) - // Optional: newNameField.text = "" // Clear field after rename } } } diff --git a/linux/assets/pod.png b/linux/assets/pod.png new file mode 100644 index 0000000..46cc952 Binary files /dev/null and b/linux/assets/pod.png differ diff --git a/linux/assets/pod3.png b/linux/assets/pod3.png new file mode 100644 index 0000000..8012474 Binary files /dev/null and b/linux/assets/pod3.png differ diff --git a/linux/assets/pod3_case.png b/linux/assets/pod3_case.png new file mode 100644 index 0000000..4852a52 Binary files /dev/null and b/linux/assets/pod3_case.png differ diff --git a/linux/assets/pod4_case.png b/linux/assets/pod4_case.png new file mode 100644 index 0000000..19abdda Binary files /dev/null and b/linux/assets/pod4_case.png differ diff --git a/linux/assets/pod_case.png b/linux/assets/pod_case.png new file mode 100644 index 0000000..64a1267 Binary files /dev/null and b/linux/assets/pod_case.png differ diff --git a/linux/assets/podmax.png b/linux/assets/podmax.png new file mode 100644 index 0000000..314bbb5 Binary files /dev/null and b/linux/assets/podmax.png differ diff --git a/linux/assets/podpro.png b/linux/assets/podpro.png new file mode 100644 index 0000000..948d4e5 Binary files /dev/null and b/linux/assets/podpro.png differ diff --git a/linux/assets/podpro_case.png b/linux/assets/podpro_case.png new file mode 100644 index 0000000..94493e4 Binary files /dev/null and b/linux/assets/podpro_case.png differ diff --git a/linux/battery.hpp b/linux/battery.hpp index e7e9c5e..3174afb 100644 --- a/linux/battery.hpp +++ b/linux/battery.hpp @@ -11,10 +11,13 @@ class Battery : public QObject Q_PROPERTY(quint8 leftPodLevel READ getLeftPodLevel NOTIFY batteryStatusChanged) Q_PROPERTY(bool leftPodCharging READ isLeftPodCharging NOTIFY batteryStatusChanged) + Q_PROPERTY(bool leftPodAvailable READ isLeftPodAvailable NOTIFY batteryStatusChanged) Q_PROPERTY(quint8 rightPodLevel READ getRightPodLevel NOTIFY batteryStatusChanged) Q_PROPERTY(bool rightPodCharging READ isRightPodCharging NOTIFY batteryStatusChanged) + Q_PROPERTY(bool rightPodAvailable READ isRightPodAvailable NOTIFY batteryStatusChanged) Q_PROPERTY(quint8 caseLevel READ getCaseLevel NOTIFY batteryStatusChanged) Q_PROPERTY(bool caseCharging READ isCaseCharging NOTIFY batteryStatusChanged) + Q_PROPERTY(bool caseAvailable READ isCaseAvailable NOTIFY batteryStatusChanged) public: explicit Battery(QObject *parent = nullptr) : QObject(parent) @@ -36,7 +39,6 @@ public: enum class BatteryStatus { - Unknown = 0, Charging = 0x01, Discharging = 0x02, Disconnected = 0x04, @@ -47,7 +49,7 @@ public: struct BatteryState { quint8 level = 0; // Battery level (0-100), 0 if unknown - BatteryStatus status = BatteryStatus::Unknown; + BatteryStatus status = BatteryStatus::Disconnected; }; // Parse the battery status packet and detect primary/secondary pods @@ -133,9 +135,6 @@ public: QString statusStr; switch (state.status) { - case BatteryStatus::Unknown: - statusStr = "Unknown"; - break; case BatteryStatus::Charging: statusStr = "Charging"; break; @@ -156,25 +155,24 @@ public: Component getSecondaryPod() const { return secondaryPod; } quint8 getLeftPodLevel() const { return states.value(Component::Left).level; } - bool isLeftPodCharging() const - { - return states.value(Component::Left).status == BatteryStatus::Charging; - } + bool isLeftPodCharging() const { return isStatus(Component::Left, BatteryStatus::Charging); } + bool isLeftPodAvailable() const { return !isStatus(Component::Left, BatteryStatus::Disconnected); } quint8 getRightPodLevel() const { return states.value(Component::Right).level; } - bool isRightPodCharging() const - { - return states.value(Component::Right).status == BatteryStatus::Charging; - } + bool isRightPodCharging() const { return isStatus(Component::Right, BatteryStatus::Charging); } + bool isRightPodAvailable() const { return !isStatus(Component::Right, BatteryStatus::Disconnected); } quint8 getCaseLevel() const { return states.value(Component::Case).level; } - bool isCaseCharging() const - { - return states.value(Component::Case).status == BatteryStatus::Charging; - } + bool isCaseCharging() const { return isStatus(Component::Case, BatteryStatus::Charging); } + bool isCaseAvailable() const { return !isStatus(Component::Case, BatteryStatus::Disconnected); } signals: void batteryStatusChanged(); private: + bool isStatus(Component component, BatteryStatus status) const + { + return states.value(component).status == status; + } + QMap states; Component primaryPod; Component secondaryPod; diff --git a/linux/enums.h b/linux/enums.h index 9a94d96..347e338 100644 --- a/linux/enums.h +++ b/linux/enums.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace AirpodsTrayApp { @@ -19,5 +20,76 @@ namespace AirpodsTrayApp MaxValue = Adaptive, }; Q_ENUM_NS(NoiseControlMode) + + enum class AirPodsModel + { + Unknown, + AirPods1, + AirPods2, + AirPods3, + AirPodsPro, + AirPodsPro2Lightning, + AirPodsPro2USBC, + AirPodsMaxLightning, + AirPodsMaxUSBC, + AirPods4, + AirPods4ANC + }; + Q_ENUM_NS(AirPodsModel) + + // Get model enum from model number + inline AirPodsModel parseModelNumber(const QString &modelNumber) + { + // Model numbers taken from https://support.apple.com/en-us/109525 + QHash modelNumberMap = { + {"A1523", AirPodsModel::AirPods1}, + {"A1722", AirPodsModel::AirPods1}, + {"A2032", AirPodsModel::AirPods2}, + {"A2031", AirPodsModel::AirPods2}, + {"A2084", AirPodsModel::AirPodsPro}, + {"A2083", AirPodsModel::AirPodsPro}, + {"A2096", AirPodsModel::AirPodsMaxLightning}, + {"A3184", AirPodsModel::AirPodsMaxUSBC}, + {"A2565", AirPodsModel::AirPods3}, + {"A2564", AirPodsModel::AirPods3}, + {"A3047", AirPodsModel::AirPodsPro2USBC}, + {"A3048", AirPodsModel::AirPodsPro2USBC}, + {"A3049", AirPodsModel::AirPodsPro2USBC}, + {"A2931", AirPodsModel::AirPodsPro2Lightning}, + {"A2699", AirPodsModel::AirPodsPro2Lightning}, + {"A2698", AirPodsModel::AirPodsPro2Lightning}, + {"A3053", AirPodsModel::AirPods4}, + {"A3050", AirPodsModel::AirPods4}, + {"A3054", AirPodsModel::AirPods4}, + {"A3056", AirPodsModel::AirPods4ANC}, + {"A3055", AirPodsModel::AirPods4ANC}, + {"A3057", AirPodsModel::AirPods4ANC}}; + + return modelNumberMap.value(modelNumber, AirPodsModel::Unknown); + } + + // Return icons based on model + inline QPair getModelIcon(AirPodsModel model) { + switch (model) { + case AirPodsModel::AirPods1: + case AirPodsModel::AirPods2: + return {"pod.png", "pod_case.png"}; + case AirPodsModel::AirPods3: + return {"pod3.png", "pod3_case.png"}; + case AirPodsModel::AirPods4: + case AirPodsModel::AirPods4ANC: + return {"pod3.png", "pod4_case.png"}; + case AirPodsModel::AirPodsPro: + case AirPodsModel::AirPodsPro2Lightning: + case AirPodsModel::AirPodsPro2USBC: + return {"podpro.png", "podpro_case.png"}; + case AirPodsModel::AirPodsMaxLightning: + case AirPodsModel::AirPodsMaxUSBC: + return {"max.png", "max_case.png"}; + default: + return {"pod.png", "pod_case.png"}; // Default icon for unknown models + } + } + } } \ No newline at end of file diff --git a/linux/main.cpp b/linux/main.cpp index eee2122..ffa6def 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -23,6 +23,10 @@ class AirPodsTrayApp : public QObject { Q_PROPERTY(QString deviceName READ deviceName NOTIFY deviceNameChanged) Q_PROPERTY(Battery* battery READ getBattery NOTIFY batteryStatusChanged) Q_PROPERTY(bool oneOrMorePodsInCase READ oneOrMorePodsInCase NOTIFY earDetectionStatusChanged) + Q_PROPERTY(QString podIcon READ podIcon NOTIFY modelChanged) + Q_PROPERTY(QString caseIcon READ caseIcon NOTIFY modelChanged) + Q_PROPERTY(bool isLeftPodInEar READ isLeftPodInEar NOTIFY earDetectionStatusChanged) + Q_PROPERTY(bool isRightPodInEar READ isRightPodInEar NOTIFY earDetectionStatusChanged) public: AirPodsTrayApp(bool debugMode) @@ -108,6 +112,22 @@ public: QString deviceName() const { return m_deviceName; } Battery *getBattery() const { return m_battery; } bool oneOrMorePodsInCase() const { return m_earDetectionStatus.contains("In case"); } + QString podIcon() const { return getModelIcon(m_model).first; } + QString caseIcon() const { return getModelIcon(m_model).second; } + bool isLeftPodInEar() const { + if (m_battery->getPrimaryPod() == Battery::Component::Left) { + return m_primaryInEar; + } else { + return m_secoundaryInEar; + } + } + bool isRightPodInEar() const { + if (m_battery->getPrimaryPod() == Battery::Component::Right) { + return m_primaryInEar; + } else { + return m_secoundaryInEar; + } + } private: bool debugMode; @@ -454,6 +474,9 @@ private slots: QString unknownHash = extractString(); QString trailingByte = extractString(); + m_model = parseModelNumber(modelNumber); + + emit modelChanged(); emit deviceNameChanged(m_deviceName); // Log extracted metadata @@ -573,6 +596,8 @@ private slots: { char primary = data[6]; char secondary = data[7]; + m_primaryInEar = primary == 0x00; + m_secoundaryInEar = secondary == 0x00; m_earDetectionStatus = QString("Primary: %1, Secondary: %2") .arg(getEarStatus(primary), getEarStatus(secondary)); LOG_INFO("Ear detection status: " << m_earDetectionStatus); @@ -828,6 +853,7 @@ signals: void conversationalAwarenessChanged(bool enabled); void adaptiveNoiseLevelChanged(int level); void deviceNameChanged(const QString &name); + void modelChanged(); private: QSystemTrayIcon *trayIcon; @@ -849,6 +875,9 @@ private: int m_adaptiveNoiseLevel = 50; QString m_deviceName; Battery *m_battery; + AirPodsModel m_model = AirPodsModel::Unknown; + bool m_primaryInEar = false; + bool m_secoundaryInEar = false; }; int main(int argc, char *argv[]) {