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:
18 files changed
+2373
-2233
No files matched your search
+570
-348
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+399
-280
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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
@@ -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"""
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user