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

+570 -348
View File
@@ -1,393 +1,615 @@
import subprocess
from fabric.utils import get_relative_path
from fabric.bluetooth import BluetoothClient, BluetoothDevice
from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger
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.box import Box
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.separator import Separator
from fabric.widgets.svg import Svg
from gi.repository import GLib
from services.battery import Battery
from utils.utils import svg_file
class EnergyModeButton(Box):
def __init__(
self,
profile_name: str,
display_name: str,
icon_name: str,
battery_service: Battery,
parent,
**kwargs,
):
super().__init__(name="energy-mode-button", **kwargs)
self.profile_name = profile_name
self.battery_service = battery_service
self.parent = parent
def get_battery_icon_file(
percentage: int, is_charging: bool, icon_path: str = ""
) -> str:
"""
Get the battery icon file path based on percentage and charging status.
self.mode_icon_svg = Svg(
# icon_name=f"battery-{icon_name}-symbolic",
svg_file=get_relative_path(
f"../../config/assets/icons/power_modes/battery-{icon_name}.svg"
),
size=24,
)
self.mode_icon = Box(
children=[self.mode_icon_svg],
name="energy-mode-icon",
style_classes="battery-profile-icon",
)
Args:
percentage: Battery percentage (0-100)
is_charging: Whether the device is charging
icon_path: Base path for icons (unused, kept for compatibility)
self.mode_label = Label(
label=display_name,
style_classes="battery-power-mode",
h_align="start",
h_expand=True,
)
Returns:
Relative path to the battery icon file
"""
clamped = max(0, min(100, percentage))
step = (clamped // 10) * 10
filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
return svg_file(f"battery/{filename}")
start_box = Box(
orientation="horizontal",
spacing=4,
children=[self.mode_icon, self.mode_label],
)
self.button = Button(
child=start_box,
h_expand=True,
name="energy-mode-button-clickable",
on_clicked=self.on_clicked,
style_classes="battery-profile-button",
)
def set_bluetooth_enabled_with_fallback(client, enabled: bool):
try:
# Try fabric bluetooth first
client.set_enabled(enabled)
except Exception as e:
logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}")
self.children = [self.button]
self.update_state()
def on_clicked(self, *args):
success = self.battery_service.change_power_profile(self.profile_name)
if success:
# Update all profile buttons in parent
self.parent.update_energy_mode_buttons()
# Reset icon state after short delay
GLib.timeout_add(300, lambda: self._reset_icon_state())
def _reset_icon_state(self):
return False # Remove timeout
def update_state(self):
is_active = self.battery_service.power_profile == self.profile_name
if is_active:
self.mode_icon.add_style_class("connected")
# Fallback to rfkill to unblock/block bluetooth
if enabled:
command = "rfkill unblock bluetooth"
else:
self.mode_icon.remove_style_class("connected")
command = "rfkill block bluetooth"
result = exec_shell_command(command)
if result is False:
logger.error(f"rfkill fallback failed: command '{command}' returned False")
elif isinstance(result, str) and result.strip():
# If result is a non-empty string, it might be an error message
logger.warning(f"rfkill command output: {result.strip()}")
else:
logger.info(f"rfkill fallback succeeded: {command}")
class GameModeButton(Box):
def __init__(self, parent, **kwargs):
super().__init__(name="energy-mode-button", h_expand=True, **kwargs)
self.parent = parent
self.game_icon = Image(
icon_name="applications-games-symbolic",
size=16,
name="game-mode-icon",
style_classes="battery-gamemode-icon",
class BluetoothDeviceSlot(CenterBox):
def __init__(self, device: BluetoothDevice, **kwargs):
super().__init__(h_expand=True, name="device-button", **kwargs)
self.device = device
self.device.connect("changed", self.on_changed)
self.device.connect(
"notify::closed", lambda *_: self.device.closed and self.destroy()
)
self.game_label = Label(
label="Game Mode",
style_classes="gamemode-button",
h_align="start",
h_expand=True,
self.styles = [
"connected" if self.device.connected else "",
"paired" if self.device.paired else "",
]
self.dimage = Image(
icon_name=device.icon_name + "-symbolic", # type: ignore
size=5,
name="device-icon",
style_classes=" ".join(self.styles),
)
start_box = Box(
orientation="horizontal",
spacing=3,
children=[self.game_icon, self.game_label],
self.device_button = Button(
on_clicked=lambda *_: self.toggle_connecting(),
child=Box(
orientation="h",
h_expand=True,
children=[self.dimage, Label(label=device.name)],
), # type: ignore
)
self.start_children = [self.device_button]
self.button = Button(
child=start_box,
name="game-mode-button-clickable",
on_clicked=self.on_clicked,
h_expand=True,
style_classes="battery-gamemode-button",
)
# # Add battery info if available
if hasattr(device, "battery_percentage") and device.battery_percentage > 0:
battery_box = Box(orientation="h", spacing=4)
self.children = [self.button]
self.update_state()
def on_clicked(self, *args):
try:
script_path = get_relative_path("../../scripts/gamemode.sh")
subprocess.run([script_path], check=False)
GLib.timeout_add(500, lambda: self.update_state())
except Exception as e:
print(f"Failed to toggle game mode: {e}")
GLib.timeout_add(300, lambda: self._reset_icon_state())
def _reset_icon_state(self):
return False # Remove timeout
def update_state(self):
try:
script_path = get_relative_path("../../scripts/gamemode.sh")
result = subprocess.run(
[script_path, "check"], capture_output=True, text=True, check=False
# Create battery icon
battery_icon = svg_file(
get_battery_icon_file(
device.battery_percentage,
False, # Not charging for bluetooth devices
)
)
is_active = result.stdout.strip() == "t"
if is_active:
self.game_icon.add_style_class("connected")
else:
self.game_icon.remove_style_class("connected")
except Exception as e:
print(f"Failed to check game mode status: {e}")
# Default to inactive state on error
self.game_icon.remove_style_class("connected")
# Create battery percentage label
battery_label = Label(
label=f"{device.battery_percentage:.0f}%", name="battery-label"
)
return False # Remove timeout if called from GLib.timeout_add
battery_box.children = [battery_icon, battery_label]
self.end_children = [battery_box]
self.device_button.connect("enter-notify-event", self.on_button_enter)
self.device_button.connect("leave-notify-event", self.on_button_leave)
self.device.emit("changed") # to update display status
def on_button_enter(self, widget, event):
self.add_style_class("button-hovered")
def on_button_leave(self, widget, event):
self.remove_style_class("button-hovered")
def toggle_connecting(self):
self.device.emit("changed")
self.device.set_connecting(not self.device.connected)
def on_changed(self, *_):
try:
# Update connection and pairing status
new_styles = [
"connected" if self.device.connected else "",
"paired" if self.device.paired else "",
]
self.styles = new_styles
self.dimage.set_property("style-classes", " ".join(self.styles))
except Exception:
return
# Update battery info if available
if (
hasattr(self.device, "battery_percentage")
and self.device.battery_percentage > 0
):
if not self.end_children: # Add battery display if not already present
battery_box = Box(orientation="h", spacing=4)
# Create battery icon
battery_icon = svg_file(
get_battery_icon_file(
self.device.battery_percentage,
False, # Not charging for bluetooth devices
)
)
# Create battery percentage label
battery_label = Label(
label=f"{self.device.battery_percentage:.0f}%", name="battery-label"
)
battery_box.children = [battery_icon, battery_label]
self.end_children = [battery_box]
else: # Update existing battery display
battery_box = self.end_children[0]
if hasattr(battery_box, "children") and len(battery_box.children) >= 2:
battery_icon = battery_box.children[0]
battery_label = battery_box.children[1]
# Update battery icon
battery_icon.dynamic_file(
get_battery_icon_file(
self.device.battery_percentage,
False, # Not charging for bluetooth devices
)
)
# Update battery percentage
battery_label.set_label(f"{self.device.battery_percentage:.0f}%")
elif self.end_children: # Remove battery display if no longer available
self.end_children = []
return
class BatteryControl(Box):
def __init__(self, parent, **kwargs):
class BluetoothConnections(Box):
def __init__(
self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs
):
super().__init__(
spacing=12,
spacing=8,
orientation="vertical",
name="control-center-widgets",
name="bluetooth-connections",
**kwargs,
)
self.set_size_request(354, -1)
self.parent = parent
self.battery_service = Battery()
self.energy_mode_buttons = []
self.show_hidden_devices = show_hidden_devices
self.is_scanning = False # Track scanning state
self.refresh_timer = None # Timer for periodic device refresh
self._update_in_progress = False # Prevent concurrent updates
self._destroyed = False # Track if widget is destroyed
self.battery_widget = Box(
name="battery-widget",
orientation="vertical",
style_classes="battery-status-section",
h_expand=True,
spacing=8,
self.client = BluetoothClient(on_device_added=self.on_device_added)
# Create pull-to-refresh indicator
self.refresh_indicator = Label(
name="bluetooth-refresh-indicator",
label="↓ Pull to scan for devices",
h_align="center",
visible=False,
style="color: #fff; font-size: 12px; padding: 5px;",
)
self.battery_title = Label(
label="Battery", style_classes="battery-main-title", h_align="start"
)
self.battery_percentage_label = Label(
label="80%", style_classes="battery-percentage", h_align="end"
)
self.battery_header = CenterBox(
start_children=self.battery_title,
end_children=self.battery_percentage_label,
name="battery-header",
)
self.power_source_label = Label(
label="Power Source: Power Adapter",
style_classes="battery-power-source",
h_align="start",
)
self.charging_time_label = Label(
label="1h 4m until fully charged",
style_classes="battery-power-source",
h_align="start",
)
self.energy_mode_section = Box(
orientation="vertical", spacing=8, name="energy-mode-section"
)
self.energy_mode_title = Label(
label="Energy Mode", style_classes="battery-section-title", h_align="start"
)
self.energy_modes_container = Box(
orientation="vertical", spacing=4, name="energy-modes-container"
)
self.game_mode_section = Box(
orientation="vertical", spacing=8, name="game-mode-section"
)
self.game_mode_title = Label(
label="Game Mode", style_classes="battery-section-title", h_align="start"
)
self.game_mode_container = Box(
orientation="vertical", spacing=4, name="game-mode-container"
)
self.battery_settings_button = Button(
v_align="center",
child=Label(
label="Battery Settings",
h_align="start",
),
style_classes="battery-settings-button",
on_clicked=self.open_battery_settings,
)
self.battery_widget.add(self.battery_header)
self.battery_widget.add(self.power_source_label)
self.battery_widget.add(self.charging_time_label)
separator1 = Separator(orientation="h", name="separator")
self.battery_widget.add(separator1)
self.energy_mode_section.add(self.energy_mode_title)
self.energy_mode_section.add(self.energy_modes_container)
self.battery_widget.add(self.energy_mode_section)
separator2 = Separator(orientation="h", name="separator")
self.battery_widget.add(separator2)
self.game_mode_section.add(self.game_mode_title)
self.game_mode_section.add(self.game_mode_container)
self.battery_widget.add(self.game_mode_section)
separator3 = Separator(orientation="h", name="separator")
self.battery_widget.add(separator3)
self.battery_widget.add(self.battery_settings_button)
self.add(self.battery_widget)
self.battery_service.connect("changed", self.on_battery_changed)
self.battery_service.connect("profile_changed", self.on_profile_changed)
# Initialize display
self.update_battery_info()
self.create_energy_mode_buttons()
self.create_game_mode_button()
def open_battery_settings(self, *args):
# TODO: Implement to open Battery Settings
pass
def create_energy_mode_buttons(self):
# Clear existing buttons
for button in self.energy_mode_buttons:
button.destroy()
self.energy_mode_buttons.clear()
# Get available profiles
available_profiles = self.battery_service.available_profiles
if not available_profiles:
no_profiles_label = Label(
label="No energy modes available",
style_classes="battery-no-profiles",
h_align="start",
)
self.energy_modes_container.add(no_profiles_label)
return
# Define energy mode mappings with proper icon names
energy_mode_config = {
"balanced": {"display": "Automatic", "icon": "balanced"},
"power-saver": {"display": "Low Power", "icon": "power"},
"powersave": {"display": "Low Power", "icon": "power"},
"performance": {"display": "High Power", "icon": "performance"},
}
# Define the desired order for energy modes
desired_order = ["balanced", "power-saver", "powersave", "performance"]
# Create ordered list of available profiles
ordered_profiles = []
for profile_name in desired_order:
if profile_name in available_profiles:
ordered_profiles.append(profile_name)
# Add any remaining profiles not in the desired order
for profile in available_profiles:
if profile not in ordered_profiles:
ordered_profiles.append(profile)
# Create button for each available profile in the specified order
for profile in ordered_profiles:
config = energy_mode_config.get(
profile, {"display": profile.title(), "icon": "good"}
)
button = EnergyModeButton(
profile_name=profile,
display_name=config["display"],
icon_name=config["icon"],
battery_service=self.battery_service,
parent=self,
)
self.energy_mode_buttons.append(button)
self.energy_modes_container.add(button)
def update_energy_mode_buttons(self):
for button in self.energy_mode_buttons:
button.update_state()
def create_game_mode_button(self):
# Clear existing game mode button if any
for child in list(self.game_mode_container.get_children()):
child.destroy()
# Create game mode button
self.game_mode_button = GameModeButton(parent=self)
self.game_mode_container.add(self.game_mode_button)
def update_battery_info(self):
if not self.battery_service.is_present:
self.battery_percentage_label.set_label("No Battery")
self.power_source_label.set_label("Power Source: Not Present")
self.charging_time_label.set_label("")
return
# Update percentage in header
percentage = self.battery_service.percentage
self.battery_percentage_label.set_label(f"{percentage}%")
# Update power source and charging info
state = self.battery_service.state
if state in ["CHARGING", "PENDING_CHARGE"]:
self.power_source_label.set_label("Power Source: Power Adapter")
time_to_full = self.battery_service.time_to_full
if time_to_full != "N/A" and time_to_full != "0m":
self.charging_time_label.set_label(
f"{time_to_full} until fully charged"
# Create title with optional back button
title_children = []
if show_back_button:
title_children.append(
Button(
image=Image(icon_name="back", size=10),
on_clicked=lambda *_: self.parent.close_bluetooth(),
)
)
title_children.append(Label("Bluetooth", name="bluetooth-title"))
self.title = Box(
orientation="h",
children=title_children,
)
self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
# Safely set initial state
self.toggle_button.set_active(self.client.enabled)
self.toggle_button.connect(
"notify::active",
lambda *_: set_bluetooth_enabled_with_fallback(
self.client, self.toggle_button.get_active()
),
)
# Connect client signals
self.client.connect(
"notify::enabled",
lambda *_: self.toggle_button.set_active(self.client.enabled),
)
self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
# Connect to device changes
self.client.connect("device-added", self.update_devices)
self.client.connect("device-removed", self.update_devices)
# Connect to additional signals for better real-time monitoring
self.client.connect("changed", self.on_client_changed)
# Create Devices section
self.paired_devices_label = Label(
label="Devices", h_align="start", name="networks-title"
)
self.paired_devices = Box(
spacing=4, orientation="vertical", name="known-networks"
)
# Create "No devices available" message
self.no_devices_label = Label(
label="No devices available",
h_align="center",
name="no-networks-label",
visible=False,
)
# Create Other Devices section with clickable title
self.other_devices_button = Button(
child=Label("Other Devices", h_align="start"),
name="wifi-other-button",
on_clicked=self.toggle_other_devices,
)
self.other_devices = Box(spacing=4, orientation="vertical")
# Create scrolled window for other devices
self.other_devices_scrolled = ScrolledWindow(
min_content_size=(303, 150),
child=self.other_devices,
overlay_scroll=True,
)
# Add pull-to-refresh functionality to scrolled window
self.setup_pull_to_refresh()
# Create revealer for Other Devices section
self.other_devices_revealer = Revealer(
child=self.other_devices_scrolled,
transition_type="slide-down",
transition_duration=100,
child_revealed=False,
)
# Create More Settings button (same style as Other Devices button)
self.more_settings_button = Button(
child=Label("More Settings", h_align="start"),
name="wifi-other-button",
on_clicked=self.open_bluetooth_settings,
)
self.children = [
CenterBox(
start_children=self.title,
end_children=self.toggle_button,
name="bluetooth-widget-top",
),
self.refresh_indicator,
Separator(orientation="h", name="separator"),
self.paired_devices_label,
self.paired_devices,
self.no_devices_label,
Separator(orientation="h", name="separator"),
self.other_devices_button,
self.other_devices_revealer,
Separator(orientation="h", name="separator"),
self.more_settings_button,
]
# Connect cleanup on destroy
self.connect("destroy", self.on_destroy)
self.client.notify("scanning")
self.client.notify("enabled")
# Initial device update
self.update_devices()
# Start periodic device monitoring for real-time updates
self.start_device_monitoring()
def toggle_other_devices(self, *_):
"""Toggle the visibility of other devices section"""
current_state = self.other_devices_revealer.child_revealed
self.other_devices_revealer.child_revealed = not current_state
# Handle scanning based on section visibility
if self.client:
if self.other_devices_revealer.child_revealed:
# Start scanning when revealing other devices section
if not self.client.scanning:
self.client.toggle_scan()
# Also force an immediate device refresh to catch any missed connections
self.force_device_refresh()
else:
self.charging_time_label.set_label("Charging...")
elif state == "FULLY_CHARGED":
self.power_source_label.set_label("Power Source: Power Adapter")
self.charging_time_label.set_label("Fully Charged")
elif state in ["DISCHARGING", "PENDING_DISCHARGE"]:
self.power_source_label.set_label("Power Source: Battery")
time_to_empty = self.battery_service.time_to_empty
if time_to_empty != "N/A" and not time_to_empty.startswith(
"4553h"
): # Filter out unrealistic times
self.charging_time_label.set_label(f"{time_to_empty} remaining")
else:
self.charging_time_label.set_label("On Battery Power")
elif state == "EMPTY":
self.power_source_label.set_label("Power Source: Battery")
self.charging_time_label.set_label("Battery Empty")
# Stop scanning when hiding other devices section
if self.client.scanning:
self.client.toggle_scan()
def open_bluetooth_settings(self, *_):
"""Open Blueman bluetooth manager"""
try:
subprocess.Popen(["blueman-manager"], start_new_session=True)
if self.parent and hasattr(self.parent, "hide_controlcenter"):
self.parent.hide_controlcenter()
except FileNotFoundError:
pass
except Exception:
pass
def update_scan_label(self):
"""Update scanning state appearance"""
if self.client.scanning:
# Show scanning feedback in refresh indicator
self.refresh_indicator.set_label("Scanning for devices...")
self.refresh_indicator.set_visible(True)
self.refresh_indicator.add_style_class("scanning")
else:
self.power_source_label.set_label("Power Source: Unknown")
self.charging_time_label.set_label("")
# Hide scanning feedback
self.refresh_indicator.set_visible(False)
self.refresh_indicator.remove_style_class("scanning")
def on_battery_changed(self, *args):
self.update_battery_info()
def update_devices(self, *_):
"""Update the list of available devices"""
# Prevent concurrent updates and check if destroyed
if self._update_in_progress or self._destroyed or not self.client:
return
def on_profile_changed(self, service, new_profile):
self.update_energy_mode_buttons()
self._update_in_progress = True
try:
# Store current device addresses to detect changes
current_paired_addresses = {
child.device.address
for child in self.paired_devices.get_children()
if hasattr(child, "device")
}
current_other_addresses = {
child.device.address
for child in self.other_devices.get_children()
if hasattr(child, "device")
}
# Get current devices safely
devices = self.client.devices
paired_devices = []
other_devices = []
new_paired_addresses = set()
new_other_addresses = set()
for device in devices:
try:
if device.name and device.name != "Unknown":
# Categorize devices: paired devices go to "Devices (Paired)"
# All others go to "Other Devices"
if device.paired:
paired_devices.append(device)
new_paired_addresses.add(device.address)
else:
other_devices.append(device)
new_other_addresses.add(device.address)
except Exception:
continue
# Check if we need to update (devices added/removed)
paired_changed = current_paired_addresses != new_paired_addresses
other_changed = current_other_addresses != new_other_addresses
# Only rebuild if something actually changed
if paired_changed or other_changed:
# Clear existing devices safely
for child in list(self.paired_devices.get_children()):
if not self._destroyed:
child.destroy()
for child in list(self.other_devices.get_children()):
if not self._destroyed:
child.destroy()
# Add paired devices
for device in paired_devices:
if not self._destroyed:
device_slot = BluetoothDeviceSlot(device)
self.paired_devices.add(device_slot)
# Add other devices
for device in other_devices:
if not self._destroyed:
device_slot = BluetoothDeviceSlot(device)
self.other_devices.add(device_slot)
# Show/hide sections based on available devices
if not self._destroyed:
has_paired_devices = len(paired_devices) > 0
has_other_devices = len(other_devices) > 0
has_any_devices = has_paired_devices or has_other_devices
# Show paired devices section only if there are paired devices
self.paired_devices_label.set_visible(has_paired_devices)
self.paired_devices.set_visible(has_paired_devices)
# Show "No devices available" message if no devices at all
self.no_devices_label.set_visible(not has_any_devices)
# Always show the other devices button, regardless of available devices
self.other_devices_button.set_visible(True) # Always visible
except Exception:
pass
finally:
self._update_in_progress = False
def start_device_monitoring(self):
"""Start periodic monitoring for device changes"""
# Monitor for device changes every 5 seconds (less aggressive)
# This helps catch devices that connect from external sources
self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh)
def stop_device_monitoring(self):
"""Stop periodic monitoring"""
if self.refresh_timer:
GLib.source_remove(self.refresh_timer)
self.refresh_timer = None
def periodic_device_refresh(self):
"""Periodically refresh device list to catch external connections"""
# Skip if update in progress, destroyed, or client not available
if (
self._update_in_progress
or self._destroyed
or not self.client
or not self.client.enabled
):
return True # Continue monitoring
try:
# Simple check - just trigger update_devices which has its own safety checks
# Don't force signal emissions as that can cause race conditions
self.update_devices()
except Exception:
pass
return True # Continue monitoring
def force_device_refresh(self):
"""Force an immediate refresh of the device list"""
if self._update_in_progress or self._destroyed:
return
try:
# Simply trigger update_devices which has its own safety checks
# Avoid forcing signal emissions to prevent race conditions
self.update_devices()
except Exception:
pass
def on_client_changed(self, *_):
"""Handle when the bluetooth client state changes"""
# Update devices when client state changes
self.update_devices()
def on_destroy(self, widget):
"""Cleanup when widget is destroyed"""
# Mark as destroyed to prevent further updates
self._destroyed = True
# Stop monitoring
self.stop_device_monitoring()
# Make sure other devices revealer is collapsed when closing
try:
self.other_devices_revealer.child_revealed = False
except:
pass # Widget might already be destroyed
def close_bluetooth(self):
"""Called when Bluetooth panel is being closed"""
# Collapse the other devices section when closing
self.other_devices_revealer.child_revealed = False
def setup_pull_to_refresh(self):
"""Setup pull-to-refresh gesture for the scrolled window"""
# Get the scrolled window's vertical adjustment
self.vadjustment = self.other_devices_scrolled.get_vadjustment()
# Track gesture state
self.pull_start_y = 0
self.is_pulling = False
self.pull_threshold = 50 # pixels to trigger refresh
# Connect to scroll events
self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event)
self.other_devices_scrolled.connect("button-press-event", self.on_button_press)
self.other_devices_scrolled.connect(
"button-release-event", self.on_button_release
)
self.other_devices_scrolled.connect(
"motion-notify-event", self.on_motion_notify
)
# Enable events
self.other_devices_scrolled.set_events(
Gdk.EventMask.SCROLL_MASK
| Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
)
def on_scroll_event(self, widget, event):
"""Handle scroll events for pull-to-refresh"""
# Only handle pull-to-refresh when at the top
if self.vadjustment.get_value() <= 0:
if event.direction == Gdk.ScrollDirection.UP:
# Scrolling up at the top - toggle scan and force refresh
self.client.toggle_scan()
self.force_device_refresh()
return True # Consume the event
return False # Let normal scrolling continue
def on_button_press(self, widget, event):
"""Handle button press for touch/drag gestures"""
if self.vadjustment.get_value() <= 0:
self.pull_start_y = event.y
self.is_pulling = True
return False
def on_button_release(self, widget, event):
"""Handle button release for touch/drag gestures"""
if self.is_pulling:
pull_distance = event.y - self.pull_start_y
if pull_distance > self.pull_threshold:
# Toggle scan and force refresh
self.client.toggle_scan()
self.force_device_refresh()
# Hide refresh indicator
self.refresh_indicator.set_visible(False)
self.refresh_indicator.remove_style_class("ready-to-refresh")
self.is_pulling = False
return False
def on_motion_notify(self, widget, event):
"""Handle motion events for visual feedback during pull"""
if self.is_pulling and self.vadjustment.get_value() <= 0:
pull_distance = event.y - self.pull_start_y
if pull_distance > 0:
# Show refresh indicator when pulling down
self.refresh_indicator.set_visible(True)
if pull_distance >= self.pull_threshold:
if self.client.scanning:
self.refresh_indicator.set_label("↑ Release to stop scanning")
else:
self.refresh_indicator.set_label("↑ Release to scan")
self.refresh_indicator.add_style_class("ready-to-refresh")
else:
if self.client.scanning:
self.refresh_indicator.set_label("↓ Pull to stop scanning")
else:
self.refresh_indicator.set_label("↓ Pull to scan for devices")
self.refresh_indicator.remove_style_class("ready-to-refresh")
else:
self.refresh_indicator.set_visible(False)
return False
def on_device_added(self, client: BluetoothClient, address: str):
"""Handle when a new device is added"""
# Update the device list when devices are added
self.update_devices()
+47 -45
View File
@@ -1,8 +1,7 @@
import subprocess
import gi
from fabric.bluetooth import BluetoothClient, BluetoothDevice
from fabric.utils import get_relative_path
from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
@@ -11,14 +10,28 @@ from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.separator import Separator
from fabric.widgets.svg import Svg
from gi.repository import Gdk, GLib, Gtk
from loguru import logger
from services.battery import Battery
from utils.utils import svg_file
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
def get_battery_icon_file(
percentage: int, is_charging: bool, icon_path: str = ""
) -> str:
"""
Get the battery icon file path based on percentage and charging status.
Args:
percentage: Battery percentage (0-100)
is_charging: Whether the device is charging
icon_path: Base path for icons (unused, kept for compatibility)
Returns:
Relative path to the battery icon file
"""
clamped = max(0, min(100, percentage))
step = (clamped // 10) * 10
filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
return svg_file(f"battery/{filename}")
def set_bluetooth_enabled_with_fallback(client, enabled: bool):
@@ -27,19 +40,21 @@ def set_bluetooth_enabled_with_fallback(client, enabled: bool):
client.set_enabled(enabled)
except Exception as e:
logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}")
# Fallback to rfkill to unblock/block bluetooth
if enabled:
command = ["rfkill", "unblock", "bluetooth"]
command = "rfkill unblock bluetooth"
else:
command = ["rfkill", "block", "bluetooth"]
command = "rfkill block bluetooth"
try:
# Execute the rfkill command
subprocess.run(
command, capture_output=True, text=True, timeout=10, check=True
)
except Exception as subprocess_error:
logger.error(f"rfkill fallback failed with exception: {subprocess_error}")
result = exec_shell_command(command)
if result is False:
logger.error(f"rfkill fallback failed: command '{command}' returned False")
elif isinstance(result, str) and result.strip():
# If result is a non-empty string, it might be an error message
logger.warning(f"rfkill command output: {result.strip()}")
else:
logger.info(f"rfkill fallback succeeded: {command}")
class BluetoothDeviceSlot(CenterBox):
@@ -73,21 +88,16 @@ class BluetoothDeviceSlot(CenterBox):
)
self.start_children = [self.device_button]
# Add battery info if available
# # Add battery info if available
if hasattr(device, "battery_percentage") and device.battery_percentage > 0:
battery_box = Box(orientation="h", spacing=4)
# Create battery icon
battery_icon = Svg(
size=16,
svg_file=get_relative_path(
Battery.get_battery_icon_file(
device.battery_percentage,
False, # Not charging for bluetooth devices
"../../config/assets/icons/",
)
),
name="battery-icon",
battery_icon = svg_file(
get_battery_icon_file(
device.battery_percentage,
False, # Not charging for bluetooth devices
)
)
# Create battery percentage label
@@ -134,16 +144,11 @@ class BluetoothDeviceSlot(CenterBox):
battery_box = Box(orientation="h", spacing=4)
# Create battery icon
battery_icon = Svg(
size=16,
svg_file=get_relative_path(
Battery.get_battery_icon_file(
self.device.battery_percentage,
False, # Not charging for bluetooth devices
"../../config/assets/icons/",
)
),
name="battery-icon",
battery_icon = svg_file(
get_battery_icon_file(
self.device.battery_percentage,
False, # Not charging for bluetooth devices
)
)
# Create battery percentage label
@@ -160,13 +165,10 @@ class BluetoothDeviceSlot(CenterBox):
battery_label = battery_box.children[1]
# Update battery icon
battery_icon.set_from_file(
get_relative_path(
Battery.get_battery_icon_file(
self.device.battery_percentage,
False, # Not charging for bluetooth devices
"../../config/assets/icons/",
)
battery_icon.dynamic_file(
get_battery_icon_file(
self.device.battery_percentage,
False, # Not charging for bluetooth devices
)
)
+93 -155
View File
@@ -1,32 +1,33 @@
# Standard library imports
import gc
import os
import re
import tempfile
import threading
import urllib.parse
import urllib.request
import threading
import weakref
from typing import List, Optional, Dict, Set
import gc
from typing import Dict, List, Optional
from fabric.utils import bulk_connect, cooldown, invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
# Fabric imports
from fabric.widgets.scale import Scale
from widgets.wayland import WaylandWindow as Window
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.box import Box
from fabric.utils import bulk_connect, invoke_repeater, cooldown
from fabric.utils.helpers import get_relative_path
from fabric.widgets.image import Image
from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack
from fabric.widgets.svg import Svg
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import GLib, GObject
from loguru import logger
import config.data as data
# Local imports
from services.mpris import MprisPlayer, MprisPlayerManager
import config.data as data
from utils.utils import svg_file
CACHE_DIR = f"{data.CACHE_DIR}/media"
@@ -46,18 +47,18 @@ def get_shared_mpris_manager():
def cleanup_artwork_cache():
"""Clean up artwork cache to prevent memory leaks."""
"""Clean up artwork cache immediately when size limit is reached."""
global _artwork_cache
if len(_artwork_cache) > _max_artwork_cache_size:
# Keep only the most recent items
# Keep only the most recent items immediately
items = list(_artwork_cache.items())
_artwork_cache = dict(items[-_max_artwork_cache_size:])
# Force garbage collection
# Immediate garbage collection
gc.collect()
def cleanup_old_cache_files():
"""Clean up old artwork cache files aggressively to save memory."""
"""Clean up old artwork cache files immediately (no lazy loading)."""
try:
if not os.path.exists(CACHE_DIR):
return
@@ -92,7 +93,7 @@ def get_artwork_cached(url: str) -> Optional[str]:
if url in _artwork_cache:
return _artwork_cache[url]
# Clean cache if too large
# Clean cache immediately if too large (no lazy loading)
cleanup_artwork_cache()
try:
@@ -125,18 +126,6 @@ def get_artwork_cached(url: str) -> Optional[str]:
logger.warning(f"Failed to cache artwork from {url}: {e}")
return None
for filename in os.listdir(CACHE_DIR):
filepath = os.path.join(CACHE_DIR, filename)
try:
if os.path.isfile(filepath):
file_mtime = os.path.getmtime(filepath)
if file_mtime < six_hours_ago:
os.unlink(filepath)
except Exception:
pass # Ignore individual file errors
except Exception:
pass # Ignore all errors in cleanup
class EmbeddedExpandedPlayer(Box):
"""Embedded expanded player widget for use inside control center."""
@@ -162,15 +151,6 @@ class EmbeddedExpandedPlayer(Box):
# Create expanded player content
self.player_content = PlayerBoxStack(self.mpris_manager)
# Add escape key binding for navigation back
self._keybinding_added = False
try:
if hasattr(self.control_center, "add_keybinding"):
self.control_center.add_keybinding("Escape", self._on_back_clicked)
self._keybinding_added = True
except Exception:
pass # Ignore if keybinding fails
self.children = [
Box(
orientation="horizontal",
@@ -221,14 +201,6 @@ class EmbeddedExpandedPlayer(Box):
except Exception as e:
logger.warning(f"Failed to destroy child widget: {e}")
# Clean up keybinding if it was added
if hasattr(self, "_keybinding_added") and self._keybinding_added:
try:
if hasattr(self.control_center, "remove_keybinding"):
self.control_center.remove_keybinding("Escape")
except Exception as e:
logger.warning(f"Failed to remove keybinding: {e}")
# Aggressively clean global caches
global _widget_cache, _artwork_cache
_widget_cache.clear()
@@ -250,35 +222,12 @@ class EmbeddedExpandedPlayer(Box):
finally:
super().destroy()
def _periodic_cleanup(self):
"""Light cleanup for EmbeddedExpandedPlayer reuse"""
try:
logger.debug("🧹 EmbeddedExpandedPlayer light cleanup starting")
# Only clean up the player content lightly
if hasattr(self, "player_content") and hasattr(
self.player_content, "_periodic_cleanup"
):
self.player_content._periodic_cleanup()
# Clean global caches but don't destroy core functionality
global _artwork_cache
_artwork_cache.clear()
# Light garbage collection
gc.collect()
logger.debug("🧹 EmbeddedExpandedPlayer light cleanup completed")
except Exception as e:
logger.warning(f"Error during EmbeddedExpandedPlayer light cleanup: {e}")
class PlayerBoxStack(Box):
"""Memory-optimized widget that displays current player information."""
def __init__(self, mpris_manager: MprisPlayerManager, **kwargs):
# Clean up old cache files on startup
# Clean up old cache files immediately (no lazy loading)
cleanup_old_cache_files()
# The player stack with memory-efficient settings
@@ -319,63 +268,16 @@ class PlayerBoxStack(Box):
self._signal_connections.append((self.mpris_manager, handler_id))
# Process existing players
for player in self.mpris_manager.players: # type: ignore
logger.info(
f"[PLAYER MANAGER] player found: {player.get_property('player-name')}"
)
for player in self.mpris_manager.players.values(): # type: ignore
logger.info(f"[PLAYER MANAGER] player found: {player.player_name}")
self.on_new_player(self.mpris_manager, player)
# Schedule periodic memory cleanup and store source ID
self._cleanup_source_id = GLib.timeout_add_seconds(
300, self._periodic_cleanup
) # Every 5 minutes
def _periodic_cleanup(self):
"""Light cleanup for widget reuse - preserve functionality."""
try:
logger.debug("Starting light expanded player cleanup for reuse")
# Clean artwork cache to reduce memory
cleanup_artwork_cache()
# Reset visual state but preserve connections and core functionality
# Only clear the player widgets that can be recreated
for widget in list(self._player_widgets.values()):
try:
if widget and hasattr(widget, "get_parent") and widget.get_parent():
# Only remove from parent, don't destroy (let GTK handle it)
widget.get_parent().remove(widget)
except Exception:
pass
# Don't clear the _player_widgets dict - just let them be recreated
# Reset stack to show no_media_box without destroying children
try:
if hasattr(self, "player_stack") and hasattr(self, "no_media_box"):
self.player_stack.set_visible_child(self.no_media_box)
self.current_stack_pos = 0
except Exception:
pass
# Light garbage collection
gc.collect()
logger.debug("Light expanded player cleanup completed")
except Exception as e:
logger.warning(f"Error during light cleanup: {e}")
return True # Continue timer
# No periodic cleanup - immediate cleanup when needed
def destroy(self):
"""Clean up resources when the widget is destroyed."""
try:
# Cancel any pending cleanup timer
if hasattr(self, "_cleanup_source_id") and self._cleanup_source_id:
try:
GLib.source_remove(self._cleanup_source_id)
except Exception:
pass # Timer may have already been removed
# No periodic cleanup timer to cancel (removed lazy loading)
# Disconnect all signal connections
for obj, handler_id in self._signal_connections:
@@ -576,7 +478,7 @@ class PlayerBoxStack(Box):
self.set_visible(True)
new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self)
new_player_box = PlayerBox(player=player, player_stack=self)
self.player_stack.children = [
*self.player_stack.children,
new_player_box,
@@ -602,7 +504,7 @@ class PlayerBoxStack(Box):
for player_box in players:
if (
hasattr(player_box, "player")
and player_box.player.player_name == player_name
and player_box.player.bus_name == player_name
):
player_box_to_remove = player_box
break
@@ -789,7 +691,7 @@ class PlayerBox(Box):
self.seek_bar.connect("value-changed", self._on_scale_value_changed)
self.seek_bar.connect("button-press-event", self._on_seek_start)
self.seek_bar.connect("button-release-event", self._on_seek_end)
self.player.bind("can-seek", "sensitive", self.seek_bar)
self.player.bind_property("can_seek", self.seek_bar, "sensitive")
# Position and length labels for seek bar
self.position_label = Label(
@@ -878,7 +780,9 @@ class PlayerBox(Box):
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist"
re.sub(r"\r?\n", " ", ", ".join(x))
if isinstance(x, list) and len(x) > 0
else "No Artist"
), # type: ignore
)
self.player.bind_property(
@@ -904,21 +808,9 @@ class PlayerBox(Box):
self.stack_buttons_box.hide() # Initially hidden
# Create SVG icons from player directory
self.skip_next_icon = Svg(
name="btn",
style_classes=["control-buttons"],
svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"),
)
self.skip_prev_icon = Svg(
name="btn",
style_classes=["control-buttons"],
svg_file=get_relative_path("../../config/assets/icons/player/Rewind.svg"),
)
self.play_pause_icon = Svg(
name="btn",
style_classes=["control-buttons"],
svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"),
)
self.skip_next_icon = svg_file("player/fwd.svg", size=22)
self.skip_prev_icon = svg_file("player/Rewind.svg", size=22)
self.play_pause_icon = svg_file("player/Pause.svg", size=22)
self.play_pause_button = Button(
style_classes=["control-buttons"],
@@ -943,6 +835,7 @@ class PlayerBox(Box):
style_classes=["control-buttons"],
on_clicked=self._on_player_prev,
)
self.player.bind_property("can_go_previous", self.prev_button, "sensitive")
self.button_box.children = (
self.prev_button,
self.play_pause_button,
@@ -989,7 +882,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,
},
@@ -998,6 +891,12 @@ class PlayerBox(Box):
for handler_id in connections:
self._signal_connections.append((self.player, handler_id))
# Start seek bar timer immediately for live updates (regardless of playback status)
self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
# Connect to realize signal to initialize seek bar properly
self.seek_bar.connect("realize", self._on_seek_bar_realized)
def destroy(self):
"""Clean up all resources when the widget is destroyed."""
# Set exit flag FIRST to stop any running timers
@@ -1119,14 +1018,24 @@ class PlayerBox(Box):
self._set_image()
duration = self.player.length
if duration:
if duration is None:
duration = 0
if duration and duration > 0:
self.length_label.set_label(self.length_str(duration))
# Clamp duration to avoid 32-bit integer overflow in the scale widget
max_int32 = 2147483647 # 2^31 - 1
safe_duration = min(max_int32, duration)
self.seek_bar.set_range(0, safe_duration)
# Cancel existing timer before starting a new one
# Only set range if seek bar is ready
if self.seek_bar.get_realized():
self.seek_bar.set_range(0, safe_duration)
else:
self.length_label.set_label("0:00")
if self.seek_bar.get_realized():
self.seek_bar.set_range(0, 100)
# Restart timer to ensure it's running with updated metadata
if self._seekbar_timer_id:
try:
from gi.repository import GLib
@@ -1161,17 +1070,17 @@ class PlayerBox(Box):
self.player.previous()
def _on_playback_change(self, player, status):
status = player.get_property("playback-status")
status = player.playback_status
if status == "paused":
self.play_pause_icon.set_from_file(
get_relative_path("../../config/assets/icons/player/play.svg")
)
if status == "Paused":
self.play_pause_icon.dynamic_file("player/play.svg")
# Keep timer running for live updates even when paused
if status == "playing":
self.play_pause_icon.set_from_file(
get_relative_path("../../config/assets/icons/player/Pause.svg")
)
if status == "Playing":
self.play_pause_icon.dynamic_file("player/Pause.svg")
# Ensure timer is running for live updates
if not self._seekbar_timer_id:
self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
def _update_image(self, image_path):
if image_path and os.path.isfile(image_path):
@@ -1292,7 +1201,14 @@ class PlayerBox(Box):
return False # Stop the timer
try:
# Check if seek bar is realized and has a valid adjustment
if not self.seek_bar.get_realized():
return True # Continue timer, widget not ready yet
position = self.player.position
if position is None:
position = 0
self.position_label.set_label(self.length_str(position))
# Only update seek bar if user is not currently seeking
@@ -1300,7 +1216,13 @@ class PlayerBox(Box):
# Clamp position to avoid 32-bit integer overflow
max_int32 = 2147483647 # 2^31 - 1
safe_position = min(max_int32, position) if position else 0
self.seek_bar.set_value(safe_position)
# Only set value if seek bar has a valid range
if (
self.seek_bar.get_adjustment()
and self.seek_bar.get_adjustment().get_upper() > 0
):
self.seek_bar.set_value(safe_position)
except Exception as e:
# If any error occurs (widget destroyed, etc), stop the timer
@@ -1319,6 +1241,22 @@ class PlayerBox(Box):
self._user_seeking = False
return False
def _on_seek_bar_realized(self, widget):
"""Initialize seek bar when it's realized"""
try:
duration = self.player.length
if duration is None:
duration = 0
if duration and duration > 0:
max_int32 = 2147483647 # 2^31 - 1
safe_duration = min(max_int32, duration)
self.seek_bar.set_range(0, safe_duration)
else:
self.seek_bar.set_range(0, 100)
except Exception as e:
logger.warning(f"Failed to initialize seek bar: {e}")
def _on_scale_value_changed(self, scale: Scale):
"""Handle seek bar value changes - only when user is seeking"""
if self.player and not self.exit and self._user_seeking:
File diff suppressed because it is too large. Load diff
+10 -18
View File
@@ -1,15 +1,11 @@
# Standard library imports
import subprocess
# Fabric imports
from fabric.utils.helpers import get_relative_path
from fabric.utils import logger
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.svg import Svg
# Local imports
from loguru import logger
from utils.utils import svg_file
class NightLightControl:
@@ -73,12 +69,11 @@ def create_night_light_widget(control_center):
night_light = NightLightControl()
# Create icon
night_light_icon = Svg(
name="nightlight-icon",
svg_file=get_relative_path(
"../../config/assets/icons/applets/redshift-status-on.svg"
night_light_icon = svg_file(
(
"applets/redshift-status-on.svg"
if night_light.is_active
else "../../config/assets/icons/applets/redshift-status-off.svg"
else "applets/redshift-status-off.svg"
),
size=42,
)
@@ -97,12 +92,10 @@ def create_night_light_widget(control_center):
"""Toggle night light and update UI"""
if night_light.toggle():
# Update icon
night_light_icon.set_from_file(
get_relative_path(
"../../config/assets/icons/applets/redshift-status-on.svg"
if night_light.is_active
else "../../config/assets/icons/applets/redshift-status-off.svg"
)
night_light_icon.dynamic_file(
"applets/redshift-status-on.svg"
if night_light.is_active
else "applets/redshift-status-off.svg"
)
# Update status label
night_light_status_label.set_label("On" if night_light.is_active else "Off")
@@ -143,4 +136,3 @@ def create_night_light_widget(control_center):
)
return night_light_widget
+2 -5
View File
@@ -1,13 +1,10 @@
# Standard library imports
from gi.repository import GLib
# Fabric imports
from fabric.utils import GLib
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.image import Image
from fabric.widgets.separator import Separator
# Local imports
+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"""
+15 -22
View File
@@ -1,22 +1,19 @@
from widgets.wifi_password_dialog import WiFiPasswordDialog
from services.network import NetworkClient
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.revealer import Revealer
from fabric.widgets.label import Label
from fabric.widgets.image import Image
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.button import Button
from fabric.widgets.box import Box
from fabric.widgets.svg import Svg
from fabric.utils import get_relative_path
from gi.repository import Gdk, GLib, Gtk
from fabric.widgets.separator import Separator
from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon
import gi
import subprocess
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from fabric.utils import Gdk, GLib, Gtk
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.revealer import Revealer
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.separator import Separator
from services.network import NetworkClient
from utils.functions import get_wifi_connecting_icon, get_wifi_icon_for_strength
from utils.utils import svg_file
from widgets.wifi_password_dialog import WiFiPasswordDialog
class WifiNetworkSlot(Box):
@@ -42,11 +39,7 @@ class WifiNetworkSlot(Box):
# Create connection status indicator using dynamic WiFi icon based on signal strength
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage = Svg(
svg_file=wifi_icon_path,
size=28,
name="device-icon",
)
self.dimage = svg_file(wifi_icon_path, size=28)
self.wifi_icon_box = Box(
children=[self.dimage],
style_classes=["wifi-icon-box"],