mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-28 00:56:07 +00:00
[Linux] Add battery indicator (#89)
* [Linux] Expose battery info to QML * [Linux] Add battery indicator * [Linux] Dynamically hide case battery level if we have no data for it * Reduce animation speed
This commit is contained in:
139
linux/BatteryIndicator.qml
Normal file
139
linux/BatteryIndicator.qml
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import QtQuick 2.15
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtQuick.Layouts 1.15
|
||||||
|
|
||||||
|
// BatteryIndicator.qml
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Public properties
|
||||||
|
property int batteryLevel: 50 // 0-100
|
||||||
|
property bool isCharging: false
|
||||||
|
property bool darkMode: false
|
||||||
|
|
||||||
|
// Private properties
|
||||||
|
readonly property color darkModeBackground: "#1C1C1E"
|
||||||
|
readonly property color lightModeBackground: "#FFFFFF"
|
||||||
|
readonly property color darkModeText: "#FFFFFF"
|
||||||
|
readonly property color lightModeText: "#000000"
|
||||||
|
|
||||||
|
readonly property color batteryLowColor: "#FF453A"
|
||||||
|
readonly property color batteryMediumColor: "#FFD60A"
|
||||||
|
readonly property color batteryHighColor: "#30D158"
|
||||||
|
readonly property color chargingColor: "#30D158"
|
||||||
|
|
||||||
|
// Size parameters
|
||||||
|
width: 85
|
||||||
|
height: 40
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
// Dynamic colors based on dark/light mode
|
||||||
|
readonly property color backgroundColor: darkMode ? darkModeBackground : lightModeBackground
|
||||||
|
readonly property color textColor: darkMode ? darkModeText : lightModeText
|
||||||
|
readonly property color borderColor: darkMode ? Qt.rgba(1, 1, 1, 0.3) : Qt.rgba(0, 0, 0, 0.3)
|
||||||
|
|
||||||
|
// Battery level color based on percentage
|
||||||
|
readonly property color levelColor: {
|
||||||
|
if (isCharging) return chargingColor;
|
||||||
|
if (batteryLevel <= 20) return batteryLowColor;
|
||||||
|
if (batteryLevel <= 50) return batteryMediumColor;
|
||||||
|
return batteryHighColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery icon
|
||||||
|
Item {
|
||||||
|
id: batteryIcon
|
||||||
|
Layout.preferredWidth: 32
|
||||||
|
Layout.preferredHeight: 16
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
|
// Main battery body
|
||||||
|
Rectangle {
|
||||||
|
id: batteryBody
|
||||||
|
width: parent.width - 2
|
||||||
|
height: parent.height
|
||||||
|
radius: 3
|
||||||
|
color: "transparent"
|
||||||
|
border.width: 1.5
|
||||||
|
border.color: root.borderColor
|
||||||
|
|
||||||
|
// Battery level fill
|
||||||
|
Rectangle {
|
||||||
|
id: batteryFill
|
||||||
|
width: Math.max(2, (batteryBody.width - 4) * (root.batteryLevel / 100))
|
||||||
|
height: batteryBody.height - 4
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 2
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
radius: 1.5
|
||||||
|
color: root.levelColor
|
||||||
|
|
||||||
|
// Animation for smooth transitions
|
||||||
|
Behavior on width {
|
||||||
|
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash effect when charging
|
||||||
|
SequentialAnimation {
|
||||||
|
running: root.isCharging
|
||||||
|
loops: Animation.Infinite
|
||||||
|
alwaysRunToEnd: true
|
||||||
|
NumberAnimation { target: batteryFill; property: "opacity"; to: 0.7; duration: 3000 }
|
||||||
|
NumberAnimation { target: batteryFill; property: "opacity"; to: 1.0; duration: 3000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery positive terminal
|
||||||
|
Rectangle {
|
||||||
|
width: 2
|
||||||
|
height: 8
|
||||||
|
radius: 1
|
||||||
|
color: root.borderColor
|
||||||
|
anchors.left: batteryBody.right
|
||||||
|
anchors.verticalCenter: batteryBody.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative charging bolt using Canvas
|
||||||
|
Canvas {
|
||||||
|
id: chargingBolt
|
||||||
|
visible: root.isCharging
|
||||||
|
width: 14
|
||||||
|
height: 14
|
||||||
|
anchors.centerIn: batteryBody
|
||||||
|
|
||||||
|
onPaint: {
|
||||||
|
var ctx = getContext("2d");
|
||||||
|
ctx.reset();
|
||||||
|
|
||||||
|
// Draw a lightning bolt
|
||||||
|
ctx.fillStyle = root.darkMode ? "#000000" : "#FFFFFF";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(7, 2); // Top point
|
||||||
|
ctx.lineTo(3, 8); // Middle left
|
||||||
|
ctx.lineTo(6, 8); // Middle center
|
||||||
|
ctx.lineTo(5, 12); // Bottom point
|
||||||
|
ctx.lineTo(11, 6); // Middle right
|
||||||
|
ctx.lineTo(8, 6); // Middle center
|
||||||
|
ctx.lineTo(9, 2); // Back to top
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ qt_add_qml_module(applinux
|
|||||||
VERSION 1.0
|
VERSION 1.0
|
||||||
QML_FILES
|
QML_FILES
|
||||||
Main.qml
|
Main.qml
|
||||||
|
BatteryIndicator.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add the resource file
|
# Add the resource file
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import QtQuick 2.15
|
import QtQuick 2.15
|
||||||
import QtQuick.Controls 2.15
|
import QtQuick.Controls 2.15
|
||||||
|
import me.kavishdevar.Battery 1.0
|
||||||
|
|
||||||
ApplicationWindow {
|
ApplicationWindow {
|
||||||
visible: true
|
visible: true
|
||||||
@@ -11,10 +12,61 @@ ApplicationWindow {
|
|||||||
spacing: 20
|
spacing: 20
|
||||||
padding: 20
|
padding: 20
|
||||||
|
|
||||||
Text {
|
// Battery Indicator
|
||||||
id: batteryStatus
|
Row {
|
||||||
text: "Battery Status: " + airPodsTrayApp.batteryStatus
|
// center the content
|
||||||
color: "#ffffff"
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
spacing: 15
|
||||||
|
|
||||||
|
Column {
|
||||||
|
spacing: 5
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Left"
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
BatteryIndicator {
|
||||||
|
batteryLevel: airPodsTrayApp.battery.leftPodLevel
|
||||||
|
isCharging: airPodsTrayApp.battery.leftPodCharging
|
||||||
|
darkMode: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
spacing: 5
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Right"
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
BatteryIndicator {
|
||||||
|
batteryLevel: airPodsTrayApp.battery.rightPodLevel
|
||||||
|
isCharging: airPodsTrayApp.battery.rightPodCharging
|
||||||
|
darkMode: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Case"
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
BatteryIndicator {
|
||||||
|
batteryLevel: airPodsTrayApp.battery.caseLevel
|
||||||
|
isCharging: airPodsTrayApp.battery.caseCharging
|
||||||
|
darkMode: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QObject>
|
||||||
|
|
||||||
#include "airpods_packets.h"
|
#include "airpods_packets.h"
|
||||||
|
|
||||||
class Battery
|
class Battery : public QObject
|
||||||
{
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
Q_PROPERTY(quint8 leftPodLevel READ getLeftPodLevel NOTIFY batteryStatusChanged)
|
||||||
|
Q_PROPERTY(bool leftPodCharging READ isLeftPodCharging NOTIFY batteryStatusChanged)
|
||||||
|
Q_PROPERTY(quint8 rightPodLevel READ getRightPodLevel NOTIFY batteryStatusChanged)
|
||||||
|
Q_PROPERTY(bool rightPodCharging READ isRightPodCharging NOTIFY batteryStatusChanged)
|
||||||
|
Q_PROPERTY(quint8 caseLevel READ getCaseLevel NOTIFY batteryStatusChanged)
|
||||||
|
Q_PROPERTY(bool caseCharging READ isCaseCharging NOTIFY batteryStatusChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
explicit Battery(QObject *parent = nullptr) : QObject(parent)
|
||||||
|
{
|
||||||
|
// Initialize all components to unknown state
|
||||||
|
states[Component::Left] = {};
|
||||||
|
states[Component::Right] = {};
|
||||||
|
states[Component::Case] = {};
|
||||||
|
}
|
||||||
|
|
||||||
// Enum for AirPods components
|
// Enum for AirPods components
|
||||||
enum class Component
|
enum class Component
|
||||||
{
|
{
|
||||||
@@ -14,6 +32,7 @@ public:
|
|||||||
Left = 0x04,
|
Left = 0x04,
|
||||||
Case = 0x08,
|
Case = 0x08,
|
||||||
};
|
};
|
||||||
|
Q_ENUM(Component)
|
||||||
|
|
||||||
enum class BatteryStatus
|
enum class BatteryStatus
|
||||||
{
|
{
|
||||||
@@ -22,6 +41,7 @@ public:
|
|||||||
Discharging = 0x02,
|
Discharging = 0x02,
|
||||||
Disconnected = 0x04,
|
Disconnected = 0x04,
|
||||||
};
|
};
|
||||||
|
Q_ENUM(BatteryStatus)
|
||||||
|
|
||||||
// Struct to hold battery level and status
|
// Struct to hold battery level and status
|
||||||
struct BatteryState
|
struct BatteryState
|
||||||
@@ -30,14 +50,6 @@ public:
|
|||||||
BatteryStatus status = BatteryStatus::Unknown;
|
BatteryStatus status = BatteryStatus::Unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Constructor: Initialize all components to unknown state
|
|
||||||
Battery()
|
|
||||||
{
|
|
||||||
states[Component::Left] = {};
|
|
||||||
states[Component::Right] = {};
|
|
||||||
states[Component::Case] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the battery status packet and detect primary/secondary pods
|
// Parse the battery status packet and detect primary/secondary pods
|
||||||
bool parsePacket(const QByteArray &packet)
|
bool parsePacket(const QByteArray &packet)
|
||||||
{
|
{
|
||||||
@@ -97,6 +109,9 @@ public:
|
|||||||
secondaryPod = podsInPacket[1]; // Second pod is secondary
|
secondaryPod = podsInPacket[1]; // Second pod is secondary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit signal to notify about battery status change
|
||||||
|
emit batteryStatusChanged();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +155,25 @@ public:
|
|||||||
Component getPrimaryPod() const { return primaryPod; }
|
Component getPrimaryPod() const { return primaryPod; }
|
||||||
Component getSecondaryPod() const { return secondaryPod; }
|
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;
|
||||||
|
}
|
||||||
|
quint8 getRightPodLevel() const { return states.value(Component::Right).level; }
|
||||||
|
bool isRightPodCharging() const
|
||||||
|
{
|
||||||
|
return states.value(Component::Right).status == BatteryStatus::Charging;
|
||||||
|
}
|
||||||
|
quint8 getCaseLevel() const { return states.value(Component::Case).level; }
|
||||||
|
bool isCaseCharging() const
|
||||||
|
{
|
||||||
|
return states.value(Component::Case).status == BatteryStatus::Charging;
|
||||||
|
}
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void batteryStatusChanged();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QMap<Component, BatteryState> states;
|
QMap<Component, BatteryState> states;
|
||||||
Component primaryPod;
|
Component primaryPod;
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ class AirPodsTrayApp : public QObject {
|
|||||||
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
|
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
|
||||||
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChanged)
|
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChanged)
|
||||||
Q_PROPERTY(QString deviceName READ deviceName NOTIFY deviceNameChanged)
|
Q_PROPERTY(QString deviceName READ deviceName NOTIFY deviceNameChanged)
|
||||||
|
Q_PROPERTY(Battery* battery READ getBattery NOTIFY batteryStatusChanged)
|
||||||
|
Q_PROPERTY(bool oneOrMorePodsInCase READ oneOrMorePodsInCase NOTIFY earDetectionStatusChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
AirPodsTrayApp(bool debugMode) : debugMode(debugMode) {
|
AirPodsTrayApp(bool debugMode)
|
||||||
|
: debugMode(debugMode)
|
||||||
|
, m_battery(new Battery(this)) {
|
||||||
if (debugMode) {
|
if (debugMode) {
|
||||||
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
|
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
|
||||||
} else {
|
} else {
|
||||||
@@ -102,6 +106,8 @@ public:
|
|||||||
bool adaptiveModeActive() const { return m_noiseControlMode == NoiseControlMode::Adaptive; }
|
bool adaptiveModeActive() const { return m_noiseControlMode == NoiseControlMode::Adaptive; }
|
||||||
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
|
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
|
||||||
QString deviceName() const { return m_deviceName; }
|
QString deviceName() const { return m_deviceName; }
|
||||||
|
Battery *getBattery() const { return m_battery; }
|
||||||
|
bool oneOrMorePodsInCase() const { return m_earDetectionStatus.contains("In case"); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool debugMode;
|
bool debugMode;
|
||||||
@@ -575,12 +581,11 @@ private slots:
|
|||||||
// Battery Status
|
// Battery Status
|
||||||
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
|
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
|
||||||
{
|
{
|
||||||
Battery battery;
|
m_battery->parsePacket(data);
|
||||||
battery.parsePacket(data);
|
|
||||||
|
|
||||||
int leftLevel = battery.getState(Battery::Component::Left).level;
|
int leftLevel = m_battery->getState(Battery::Component::Left).level;
|
||||||
int rightLevel = battery.getState(Battery::Component::Right).level;
|
int rightLevel = m_battery->getState(Battery::Component::Right).level;
|
||||||
int caseLevel = battery.getState(Battery::Component::Case).level;
|
int caseLevel = m_battery->getState(Battery::Component::Case).level;
|
||||||
m_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)
|
||||||
@@ -843,6 +848,7 @@ private:
|
|||||||
bool m_conversationalAwareness = false;
|
bool m_conversationalAwareness = false;
|
||||||
int m_adaptiveNoiseLevel = 50;
|
int m_adaptiveNoiseLevel = 50;
|
||||||
QString m_deviceName;
|
QString m_deviceName;
|
||||||
|
Battery *m_battery;
|
||||||
};
|
};
|
||||||
|
|
||||||
int main(int argc, char *argv[]) {
|
int main(int argc, char *argv[]) {
|
||||||
@@ -857,6 +863,7 @@ int main(int argc, char *argv[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
|
qmlRegisterType<Battery>("me.kavishdevar.Battery", 1, 0, "Battery");
|
||||||
AirPodsTrayApp trayApp(debugMode);
|
AirPodsTrayApp trayApp(debugMode);
|
||||||
engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp);
|
engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp);
|
||||||
engine.loadFromModule("linux", "Main");
|
engine.loadFromModule("linux", "Main");
|
||||||
|
|||||||
Reference in New Issue
Block a user