[Linux] Enhance GUI with icons (#90)

* [Linux] Enhance GUI with icons

* Improve visibility

* Smarter hiding of battery values

* Add simple opacity based ear detection indication

* Hide disconnected devices

* Add airpods 3 icon

* Support more devices

* Better icons

* Add documentation
This commit is contained in:
Tim Gromeyer
2025-03-31 22:57:12 +02:00
committed by GitHub
parent 4e72f6573e
commit e3dab8feb2
14 changed files with 204 additions and 47 deletions

View File

@@ -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"
}
}
}
}

View File

@@ -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

View File

@@ -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
}
}
}

BIN
linux/assets/pod.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
linux/assets/pod3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

BIN
linux/assets/pod3_case.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
linux/assets/pod4_case.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
linux/assets/pod_case.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
linux/assets/podmax.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

BIN
linux/assets/podpro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -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<Component, BatteryState> states;
Component primaryPod;
Component secondaryPod;

View File

@@ -1,6 +1,7 @@
#pragma once
#include <QMetaType>
#include <QHash>
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<QString, AirPodsModel> 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<QString, QString> 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
}
}
}
}

View File

@@ -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[]) {