[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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 13 KiB |
BIN
linux/assets/pod3.png
Normal file
|
After Width: | Height: | Size: 854 KiB |
BIN
linux/assets/pod3_case.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
linux/assets/pod4_case.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
linux/assets/pod_case.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
linux/assets/podmax.png
Normal file
|
After Width: | Height: | Size: 484 KiB |
BIN
linux/assets/podpro.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
linux/assets/podpro_case.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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[]) {
|
||||
|
||||