feat(modules): restructure battery UI and add dynamic layout rebuilding

New Features
- Introduce `get_battery_icon_file` to generate battery icons based on percentage and charging state.
- Add fallback Bluetooth control using `rfkill` when Fabric Bluetooth fails.

Enhancements
- Refactor layout management with `_rebuild_layout_from_config` and dynamic indicator instantiation.
- Replace large `EnergyModeButton` class with lightweight utility functions.
- Simplify notification icon updates using `dynamic_file`.

Bug Fixes
- No explicit bug fixes; changes focus on feature additions and refactoring.
This commit is contained in:
sankalp committed 2026-02-26 06:24:44 +05:45
1 parent 15be2f3de6
commit e97311e2b0
18 files changed
+2373 -2233

No files matched your search

+291 -203
View File
@@ -1,10 +1,7 @@
import os
import re
import tempfile
import urllib.parse
import urllib.request
import time
from typing import List
import threading
from fabric.utils import (
bulk_connect,
@@ -12,19 +9,47 @@ from fabric.utils import (
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack
from fabric.widgets.svg import Svg
from gi.repository import GLib, GObject
from fabric.widgets.centerbox import CenterBox
from gi.repository import Gio, GLib, GObject
from loguru import logger
from services.mpris import MprisPlayer, MprisPlayerManager
import config.data as data
from services.mpris import MprisPlayer, MprisPlayerManager
from utils.utils import svg_file
CACHE_DIR = f"{data.CACHE_DIR}/media"
MEDIA_CACHE = CACHE_DIR
if not os.path.exists(CACHE_DIR):
os.makedirs(CACHE_DIR)
if not os.path.exists(MEDIA_CACHE):
os.makedirs(MEDIA_CACHE)
# Global shared MPRIS manager
_shared_mpris_manager = None
def get_shared_mpris_manager():
"""Get shared MPRIS manager instance to reduce memory usage."""
global _shared_mpris_manager
if _shared_mpris_manager is None:
# Create MPRIS manager immediately but don't wait for DBus discovery
_shared_mpris_manager = MprisPlayerManager()
return _shared_mpris_manager
def cleanup_shared_mpris_manager():
"""Clean up the shared MPRIS manager to free memory."""
global _shared_mpris_manager
if _shared_mpris_manager is not None:
try:
_shared_mpris_manager.destroy()
except Exception as e:
logger.warning(f"Failed to destroy shared MPRIS manager: {e}")
_shared_mpris_manager = None
def cleanup_old_cache_files():
@@ -33,8 +58,6 @@ def cleanup_old_cache_files():
if not os.path.exists(CACHE_DIR):
return
import time
current_time = time.time()
one_day_ago = current_time - (24 * 60 * 60) # 24 hours
cache_files = []
@@ -85,10 +108,10 @@ class PlayerBoxStack(Box):
"""A widget that displays the current player information."""
def __init__(
self, mpris_manager: MprisPlayerManager, control_center=None, **kwargs
self, mpris_manager: MprisPlayerManager = None, control_center=None, **kwargs
):
# Clean up old cache files on startup
cleanup_old_cache_files()
# Defer cache cleanup to avoid blocking startup
GLib.idle_add(cleanup_old_cache_files)
# The player stack
self.player_stack = Stack(
@@ -114,25 +137,96 @@ class PlayerBoxStack(Box):
self.player_stack.children = [self.no_media_box]
self.set_visible(True)
# Use shared MPRIS manager if none provided
self.mpris_manager = mpris_manager
# Track connections for cleanup - store (object, handler_id) tuples
connections = bulk_connect(
self.mpris_manager,
{
"player-appeared": self.on_new_player,
"player-vanished": self.on_lost_player,
},
)
# Store as (object, handler_id) tuples
for handler_id in connections:
self._signal_connections.append((self.mpris_manager, handler_id))
# Initialize MPRIS manager if provided, otherwise defer
if self.mpris_manager is not None:
self._init_mpris_manager()
else:
# Will be initialized later via _init_mpris_manager()
pass
for player in self.mpris_manager.players: # type: ignore
logger.info(
f"[PLAYER MANAGER] player found: {player.get_property('player-name')}",
# Connect to visibility changes for cleanup
self.connect("notify::visible", self._on_visibility_changed)
def _on_visibility_changed(self, widget, param):
"""Monitor visibility changes to debug hiding issues."""
is_visible = self.get_visible()
logger.debug(f"[PlayerBoxStack] Visibility changed to: {is_visible}")
if not is_visible:
# Check if we should be visible
current_children = self.player_stack.get_children()
if len(current_children) > 0:
logger.warning(
f"[PlayerBoxStack] Widget was hidden but has {len(current_children)} children - forcing visibility"
)
GLib.idle_add(self.set_visible, True)
def set_visible(self, visible: bool) -> None:
"""Override set_visible to ensure the widget is never hidden when it should show content."""
# Always ensure the widget is visible if we have content to show
if not visible:
current_children = self.player_stack.get_children()
if len(current_children) > 0:
# Don't allow hiding if we have content
logger.debug(
"[PlayerBoxStack] Preventing widget from being hidden - has content"
)
return
super().set_visible(visible)
def get_visible(self) -> bool:
"""Override get_visible to ensure the widget always reports as visible when it has content."""
# Check if we have content to show
current_children = self.player_stack.get_children()
if len(current_children) > 0:
# If we have content, we should always be visible
return True
# Otherwise, use the parent's visibility state
return super().get_visible()
def _init_mpris_manager(self):
"""Initialize MPRIS manager and connect signals"""
if self.mpris_manager is None:
return
try:
# Track connections for cleanup - store (object, handler_id) tuples
connections = bulk_connect(
self.mpris_manager,
{
"player-appeared": self.on_new_player,
"player-vanished": self.on_lost_player,
},
)
self.on_new_player(self.mpris_manager, player)
# Store as (object, handler_id) tuples
for handler_id in connections:
self._signal_connections.append((self.mpris_manager, handler_id))
# Process existing players asynchronously to avoid blocking
def process_existing_players():
try:
for player in self.mpris_manager.players.values(): # type: ignore
self.on_new_player(self.mpris_manager, player)
logger.debug("PlayerBoxStack initialized successfully")
except Exception as e:
logger.error(f"Failed to process existing players: {e}")
# Use idle_add to process players asynchronously
GLib.idle_add(process_existing_players)
# Also check the playing state after a short delay to ensure consistency
GLib.timeout_add(1000, self._check_and_update_playing_state)
# Ensure the widget is always visible
GLib.timeout_add(500, self._ensure_visibility)
except Exception as e:
logger.error(f"Failed to initialize PlayerBoxStack signals: {e}")
def destroy(self):
"""Clean up resources when the widget is destroyed."""
@@ -160,6 +254,14 @@ class PlayerBoxStack(Box):
except Exception:
pass
# Clean up shared MPRIS manager if this is the last instance
if (
hasattr(self, "mpris_manager")
and self.mpris_manager == _shared_mpris_manager
):
# Only cleanup if no other widgets are using it
cleanup_shared_mpris_manager()
super().destroy()
def _periodic_cleanup(self):
@@ -191,18 +293,12 @@ class PlayerBoxStack(Box):
# Clean up old cache files more aggressively
cleanup_old_cache_files()
# Force garbage collection
import gc
gc.collect()
logger.debug("PlayerBoxStack enhanced cleanup completed")
except Exception as e:
logger.warning(f"PlayerBoxStack enhanced cleanup failed: {e}")
def _create_no_media_box(self):
"""Create a placeholder box for when no media is playing."""
fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
fallback_cover_path = get_relative_path("../../assets/icons/music.svg")
# Album cover with fallback image
album_cover = Box(style_classes="album-image-c")
@@ -305,7 +401,7 @@ class PlayerBoxStack(Box):
for i, player_box in enumerate(players):
if (
hasattr(player_box, "player")
and player_box.player.playback_status == "playing"
and str(player_box.player.playback_status).lower() == "playing"
):
return i
return None
@@ -314,28 +410,104 @@ class PlayerBoxStack(Box):
"""Switch to the currently playing player if one exists."""
playing_index = self._find_playing_player_index()
if playing_index is not None and playing_index != self.current_stack_pos:
logger.info(
f"[PlayerBoxStack] Auto-switching to playing player at index {
playing_index
}"
)
self.on_player_clicked_by_index(playing_index)
def _check_and_update_playing_state(self):
"""Check if any players are playing and update the display accordingly."""
players: List[PlayerBox] = self.player_stack.get_children()
has_playing_player = False
# Check if any player is currently playing
for player_box in players:
if (
hasattr(player_box, "player")
and str(player_box.player.playback_status).lower() == "playing"
):
has_playing_player = True
break
# If no players are playing, show the no media box
if not has_playing_player:
# Check if we already have the no media box visible
current_children = self.player_stack.get_children()
if len(current_children) == 1 and current_children[0] == self.no_media_box:
# Already showing no media, no need to change
return
# Hide all player boxes and show no media
for player_box in players:
if hasattr(player_box, "destroy"):
try:
player_box.destroy()
except Exception as e:
logger.warning(f"Failed to destroy player box: {e}")
# Clear player buttons
self.player_buttons.clear()
# Show the no media box
self.player_stack.children = [self.no_media_box]
self.current_stack_pos = 0
# Ensure the widget is visible
self.set_visible(True)
self.player_stack.set_visible_child(self.no_media_box)
logger.debug(
"[PlayerBoxStack] No players playing, showing 'No media playing'"
)
def _ensure_visibility(self):
"""Ensure the widget is always visible and showing appropriate content."""
try:
# Always ensure the widget is visible
self.set_visible(True)
# Check current state
current_children = self.player_stack.get_children()
# If no children, show no media box
if len(current_children) == 0:
self.player_stack.children = [self.no_media_box]
self.current_stack_pos = 0
logger.debug("[PlayerBoxStack] No children found, showing no media box")
# If only no_media_box is visible, ensure it's the active child
elif (
len(current_children) == 1 and current_children[0] == self.no_media_box
):
self.player_stack.set_visible_child(self.no_media_box)
logger.debug("[PlayerBoxStack] Ensuring no media box is visible")
# Force the widget to be visible and show content
self.set_visible(True)
self.player_stack.set_visible(True)
# If we have the no media box, make sure it's visible
if self.no_media_box in current_children:
self.no_media_box.set_visible(True)
logger.debug(
f"[PlayerBoxStack] Current state: {len(current_children)} children, visible: {self.get_visible()}, stack_visible: {self.player_stack.get_visible()}"
)
except Exception as e:
logger.error(f"[PlayerBoxStack] Error in _ensure_visibility: {e}")
def on_player_playback_changed(self, player_box, status):
"""Called when a player's playback status changes."""
status = str(status).lower() if isinstance(status, str) else status
if status == "playing":
# Find this player's index and switch to it
players: List[PlayerBox] = self.player_stack.get_children()
for i, pb in enumerate(players):
if pb == player_box:
if i != self.current_stack_pos:
logger.info(
f"[PlayerBoxStack] Switching to playing player: {
player_box.player.player_name
}"
)
self.on_player_clicked_by_index(i)
break
elif status in ["paused", "stopped"]:
# Check if any other players are playing
self._check_and_update_playing_state()
def on_player_clicked(self, type):
# unset active from prev active button
@@ -357,11 +529,6 @@ class PlayerBoxStack(Box):
# set new active button
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
print(
f"[PlayerBoxStack] Switching to player at index {
self.current_stack_pos
}"
)
self.player_buttons[self.current_stack_pos].add_style_class("active")
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
@@ -394,6 +561,11 @@ class PlayerBoxStack(Box):
# if player_name in self.config.get("ignore", []):
# return
# Show all players, but we'll handle their playback status in the UI
# if player.playback_status != "playing":
# logger.debug(f"[PlayerBoxStack] Player {player_name} is not playing (status: {player.playback_status}), skipping display")
# return
# Remove the no media box if it's the only child
if (
len(self.player_stack.get_children()) == 1
@@ -405,7 +577,7 @@ class PlayerBoxStack(Box):
self.set_visible(True)
new_player_box = PlayerBox(
player=MprisPlayer(player),
player=player,
player_stack=self,
control_center=self.control_center,
)
@@ -415,9 +587,6 @@ class PlayerBoxStack(Box):
]
self.make_new_player_button(self.player_stack.get_children()[-1])
logger.info(
f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}",
)
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
@@ -427,9 +596,8 @@ class PlayerBoxStack(Box):
# Check if this new player is playing and switch to it
self._switch_to_playing_player()
def on_lost_player(self, mpris_manager, player_name):
def on_lost_player(self, mpris_manager, bus_name):
# the playerBox is automatically removed from mprisbox children on being removed
logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}")
players: List[PlayerBox] = self.player_stack.get_children()
# Find and properly destroy the player box
@@ -437,7 +605,7 @@ class PlayerBoxStack(Box):
for player_box in players:
if (
hasattr(player_box, "player")
and player_box.player.player_name == player_name
and getattr(player_box.player, "bus_name", None) == bus_name
):
player_box_to_remove = player_box
break
@@ -510,11 +678,7 @@ class PlayerBoxStack(Box):
def _update_all_player_buttons(self):
"""Update all player boxes with the current button state"""
players: List[PlayerBox] = self.player_stack.get_children()
logger.info(
f"[PlayerBoxStack] Updating buttons for {len(players)} players, {
len(self.player_buttons)
} buttons"
)
for player_box in players:
if hasattr(player_box, "update_buttons"):
player_box.update_buttons(self.player_buttons, len(players) > 1)
@@ -540,7 +704,7 @@ class PlayerBox(Box):
self.player: MprisPlayer = player
self.player_stack = player_stack
self.control_center = control_center
self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
self.cover_path = get_relative_path("../../assets/icons/music.svg")
# Add controls_box attribute early for compatibility
# Temporary placeholder
@@ -552,24 +716,25 @@ class PlayerBox(Box):
# State
self.exit = False
self.skipped = False
self._signal_connections = [] # Track signal connections
# Memory management
self.temp_artwork_files = [] # Track temp files for cleanup
self.current_download_thread = None # Track current download thread
self._download_cancelled = False # Flag to cancel downloads
self._signal_connections = [] # Track signal connections
# Album art with existing Box widget
self.album_cover = Box(style_classes="album-image-c")
self.album_cover.set_style(
f"background-image:url('{self.fallback_cover_path}')"
)
self.album_cover.set_style(f"background-image:url('{self.cover_path}')")
self.image_stack = Box(
h_align="start",
v_align="center",
name="player-image-stack",
)
self.image_stack.children = [*self.image_stack.children, self.album_cover]
self.image_stack.children = [self.album_cover]
# Connect to arturl changes
self.player.connect("notify::arturl", self.set_image)
self.app_icon = Box(
children=Image(
@@ -585,6 +750,7 @@ class PlayerBox(Box):
self.app_icon,
],
)
# Track Info
self.track_title = Label(
@@ -620,9 +786,7 @@ class PlayerBox(Box):
self.track_artist,
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist"
), # type: ignore
lambda _, x: (", ".join(x) if x and isinstance(x, list) else "No Artist"), # type: ignore
)
self.track_info = Box(
@@ -645,16 +809,8 @@ class PlayerBox(Box):
)
# Create SVG icons with consistent sizing
self.skip_next_icon = Svg(
name="control-buttons",
size=(22, 22),
svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"),
)
self.play_pause_icon = Svg(
name="control-buttons",
size=(22, 22),
svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"),
)
self.skip_next_icon = svg_file("player/fwd.svg", size=22)
self.play_pause_icon = svg_file("player/Pause.svg", size=22)
# Fixed size buttons to prevent layout shifts
self.play_pause_button = Button(
@@ -709,15 +865,9 @@ class PlayerBox(Box):
spacing=5,
name="outer-player-box-c",
h_expand=True,
# style="background-color:#fff",
on_clicked=self._on_outer_box_clicked,
h_align="start",
# children=[
child=self.inner_box,
# self.inner_box,
# self.player_info_box,
# self.image,
# ],
)
self.box = CenterBox(
@@ -726,12 +876,12 @@ class PlayerBox(Box):
h_align="center",
start_children=[
self.outer_box,
# self.stack_buttons_box,
],
end_children=[
self.button_box,
],
)
self.box.set_size_request(352, -1)
self.children = [
*self.children,
@@ -742,7 +892,7 @@ class PlayerBox(Box):
connections = bulk_connect(
self.player,
{
"exit": self._on_player_exit,
"closed": self._on_player_exit,
"notify::playback-status": self._on_playback_change,
"notify::metadata": self._on_metadata,
},
@@ -797,16 +947,13 @@ class PlayerBox(Box):
self.control_center.open_expanded_player()
except Exception as e:
logger.warning(f"Failed to handle outer box click: {e}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
def update_buttons(self, player_buttons, show_buttons):
# """Update the stack switcher buttons in this player box"""
pass
def _on_metadata(self, *_):
self._set_image()
self.set_image()
def _cleanup_temp_files(self):
"""Clean up temporary artwork files."""
@@ -831,124 +978,65 @@ class PlayerBox(Box):
def _on_playback_change(self, player, status):
status = player.get_property("playback-status")
status_l = str(status).lower() if isinstance(status, str) else status
if status == "paused":
self.play_pause_icon.set_from_file(
get_relative_path("../../config/assets/icons/player/play.svg")
)
if status_l == "paused":
self.play_pause_icon.dynamic_file("player/play.svg")
if status == "playing":
self.play_pause_icon.set_from_file(
get_relative_path("../../config/assets/icons/player/Pause.svg")
)
# Notify the player stack that this player started playing
if self.player_stack and hasattr(
self.player_stack, "on_player_playback_changed"
):
self.player_stack.on_player_playback_changed(self, status)
if status_l == "playing":
self.play_pause_icon.dynamic_file("player/Pause.svg")
def _update_image(self, image_path):
if image_path and os.path.isfile(image_path):
self.album_cover.set_style(f"background-image:url('{image_path}')")
else:
self.album_cover.set_style(
f"background-image:url('{self.fallback_cover_path}')"
)
def _set_image(self, *_):
art_url = self.player.arturl
parsed = urllib.parse.urlparse(art_url)
if parsed.scheme == "file":
local_arturl = urllib.parse.unquote(parsed.path)
self._update_image(local_arturl)
elif parsed.scheme in ("http", "https"):
# Cancel any existing download to prevent memory buildup
self._download_cancelled = True
# Use threading.Thread instead of GLib.Thread for better control
if self.current_download_thread and self.current_download_thread.is_alive():
# Thread will check _download_cancelled flag and exit early
pass
self._download_cancelled = False
self.current_download_thread = threading.Thread(
target=self._download_and_set_artwork,
args=(art_url,),
daemon=True, # Dies with main thread
)
self.current_download_thread.start()
else:
self._update_image(art_url)
def _download_and_set_artwork(self, arturl):
"""
Download the artwork from the given URL asynchronously and update the cover
using GLib.idle_add to ensure UI updates occur on the main thread.
"""
local_arturl = self.fallback_cover_path
temp_file_path = None
# Always notify the player stack about playback status changes
if self.player_stack and hasattr(
self.player_stack, "on_player_playback_changed"
):
self.player_stack.on_player_playback_changed(self, status_l)
def img_callback(self, source: Gio.File, result: Gio.AsyncResult):
try:
# Check if download was cancelled
if self._download_cancelled:
return
if os.path.isfile(self.cover_path):
self.update_image()
except ValueError:
logger.error("[PLAYER] Failed to grab artUrl")
# Clean up old temp files first (keep only last 1 to reduce memory)
if len(self.temp_artwork_files) > 1:
old_files = self.temp_artwork_files[:-1]
for old_file in old_files:
try:
if os.path.exists(old_file):
os.unlink(old_file)
except Exception:
pass
self.temp_artwork_files = self.temp_artwork_files[-1:]
def update_image(self):
self.album_cover.set_style(f"background-image:url('{self.cover_path}')")
# Check again if cancelled
if self._download_cancelled:
return
def set_image(self, *args):
url = self.player.arturl
# Download artwork
parsed = urllib.parse.urlparse(arturl)
suffix = os.path.splitext(parsed.path)[1] or ".png"
with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout
if self._download_cancelled:
return
data = response.read()
# Check one more time if cancelled
if self._download_cancelled:
return
# Create temp file in cache directory instead of system temp
os.makedirs(CACHE_DIR, exist_ok=True)
with tempfile.NamedTemporaryFile(
delete=False, suffix=suffix, dir=CACHE_DIR
) as temp_file:
temp_file.write(data)
temp_file_path = temp_file.name
local_arturl = temp_file_path
# Track temp file for cleanup
if temp_file_path and not self._download_cancelled:
self.temp_artwork_files.append(temp_file_path)
except Exception as e:
if not self._download_cancelled:
logger.warning(f"Failed to download artwork from {arturl}: {e}")
# Clean up failed temp file
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
except Exception:
pass
if url is None or url == "":
return
# Only update UI if not cancelled
if not self._download_cancelled:
GLib.idle_add(self._update_image, local_arturl)
new_cover_path = (
(
MEDIA_CACHE
+ "/"
# type: ignore
+ GLib.compute_checksum_for_string(GLib.ChecksumType.SHA1, url, -1)
)
if "file://" != url[0:7]
else url[7:]
)
if new_cover_path == self.cover_path:
self.update_image()
return
self.cover_path = new_cover_path
if os.path.exists(self.cover_path):
self.update_image()
return
Gio.File.new_for_uri(uri=url).copy_async(
Gio.File.new_for_path(self.cover_path),
Gio.FileCopyFlags.OVERWRITE,
GLib.PRIORITY_DEFAULT,
None,
None,
self.img_callback,
)
def close_bluetooth(self, *args):
"""Placeholder method for compatibility"""