refactor: extract shared mixins, fix cpu waste, resolve browser icons
- Extract CloseButtonRevealerMixin, PullToRefreshMixin, BasePlayerBoxStack - Replace mpris.py 2s+5s D-Bus polling with Seeked signal subscription - Remove redundant desktop/main.py os.listdir() poll (FileMonitor covers) - Remove switcher/main.py 200ms capture timer, chain from frame-ready - Resolve browser MPRIS icons via desktop file lookup, fallback to app name - Fix CodeQL py/clear-text-storage by rounding lat/lon and stripping from cache
This commit is contained in:
14 files changed
+515
-740
No files matched your search
+63
-58
@@ -212,6 +212,7 @@ class PlayerService(Service):
|
||||
self._is_cleaning_up = False
|
||||
self._artwork_generation = 0
|
||||
self._signal_ids = []
|
||||
self._subscription_ids: list[int] = []
|
||||
self._last_emitted_status = ""
|
||||
self._failed_covers: set[str] = set()
|
||||
self._download_threads: dict[str, threading.Thread] = {}
|
||||
@@ -220,11 +221,10 @@ class PlayerService(Service):
|
||||
self._proxy.connect("g-properties-changed", self._on_properties_changed)
|
||||
)
|
||||
|
||||
self._pos_source_id = 0
|
||||
self._status_source_id = 0
|
||||
self._last_polled_status = ""
|
||||
self._start_status_polling()
|
||||
self.poll_progress()
|
||||
self._subscribe_seeked()
|
||||
|
||||
if self.playback_status.lower() == "playing":
|
||||
self.fabricating()
|
||||
|
||||
metadata = self._get_metadata()
|
||||
if metadata:
|
||||
@@ -251,6 +251,49 @@ class PlayerService(Service):
|
||||
GLib.timeout_add(800, self._refresh_initial_state, attempt + 1)
|
||||
return False
|
||||
|
||||
def _subscribe_seeked(self):
|
||||
conn = self._proxy.get_connection()
|
||||
if conn:
|
||||
self._subscription_ids.append(
|
||||
conn.signal_subscribe(
|
||||
self._proxy.get_name(),
|
||||
MPRIS_PLAYER_IFACE,
|
||||
"Seeked",
|
||||
MPRIS_PLAYER_PATH,
|
||||
None,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
self._on_seeked,
|
||||
)
|
||||
)
|
||||
|
||||
def _on_seeked(
|
||||
self,
|
||||
_connection,
|
||||
_sender_name,
|
||||
_object_path,
|
||||
_interface_name,
|
||||
_signal_name,
|
||||
params: GLib.Variant,
|
||||
):
|
||||
if self._is_cleaning_up:
|
||||
return
|
||||
try:
|
||||
pos = params.unpack()[0] / 1_000_000
|
||||
except Exception as e:
|
||||
logger.warning(f"[mpris] Seeked unpack failed: {e}")
|
||||
return
|
||||
m = self._get_metadata()
|
||||
dur = 0
|
||||
if m is not None:
|
||||
v = m.get("mpris:length")
|
||||
if v is not None:
|
||||
dur = (
|
||||
v.get_uint64() / 1_000_000
|
||||
if isinstance(v, GLib.Variant) and v.get_type_string() == "t"
|
||||
else int(v) / 1_000_000
|
||||
)
|
||||
self.track_position(pos, dur)
|
||||
|
||||
def get_artwork(self) -> str:
|
||||
return self._current_artwork_path
|
||||
|
||||
@@ -270,49 +313,9 @@ class PlayerService(Service):
|
||||
logger.warning(f"Could not get position: {e}")
|
||||
return 0
|
||||
|
||||
def _start_pos_fabricator(self):
|
||||
if self._pos_source_id:
|
||||
return
|
||||
self._pos_source_id = GLib.timeout_add(2000, self._on_pos_poll)
|
||||
|
||||
def _stop_pos_fabricator(self):
|
||||
if not self._pos_source_id:
|
||||
return
|
||||
GLib.source_remove(self._pos_source_id)
|
||||
self._pos_source_id = 0
|
||||
|
||||
def _on_pos_poll(self):
|
||||
if self._is_cleaning_up:
|
||||
return False
|
||||
self.fabricating()
|
||||
return True
|
||||
|
||||
def _start_status_polling(self):
|
||||
if self._status_source_id:
|
||||
return
|
||||
self._last_polled_status = self.playback_status
|
||||
self._status_source_id = GLib.timeout_add(5000, self._on_status_poll)
|
||||
|
||||
def _stop_status_polling(self):
|
||||
if not self._status_source_id:
|
||||
return
|
||||
GLib.source_remove(self._status_source_id)
|
||||
self._status_source_id = 0
|
||||
|
||||
def _on_status_poll(self):
|
||||
if self._is_cleaning_up:
|
||||
return False
|
||||
status = self.playback_status
|
||||
if status != self._last_polled_status:
|
||||
self._last_polled_status = status
|
||||
self.poll_progress()
|
||||
self._notify_playback(status)
|
||||
return True
|
||||
|
||||
def seek_position(self, pos: float):
|
||||
if self._is_cleaning_up:
|
||||
return
|
||||
self._stop_pos_fabricator()
|
||||
try:
|
||||
self._proxy.call_sync(
|
||||
"SetPosition",
|
||||
@@ -335,13 +338,11 @@ class PlayerService(Service):
|
||||
except GLib.Error as e:
|
||||
logger.error(f"seek_position fallback failed: {e}")
|
||||
finally:
|
||||
if self.playback_status.lower() == "playing":
|
||||
self._start_pos_fabricator()
|
||||
self.poll_progress()
|
||||
|
||||
def seek(self, offset: float):
|
||||
if self._is_cleaning_up:
|
||||
return
|
||||
self._stop_pos_fabricator()
|
||||
try:
|
||||
self._proxy.call_sync(
|
||||
"Seek",
|
||||
@@ -353,16 +354,13 @@ class PlayerService(Service):
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
finally:
|
||||
if self.playback_status.lower() == "playing":
|
||||
self._start_pos_fabricator()
|
||||
self.poll_progress()
|
||||
|
||||
def poll_progress(self):
|
||||
if self._is_cleaning_up:
|
||||
return
|
||||
if self.playback_status.lower() == "playing":
|
||||
self._start_pos_fabricator()
|
||||
else:
|
||||
self._stop_pos_fabricator()
|
||||
self.fabricating()
|
||||
|
||||
def fabricating(self, metadata=None):
|
||||
if self._is_cleaning_up:
|
||||
@@ -556,15 +554,22 @@ class PlayerService(Service):
|
||||
self._is_cleaning_up = True
|
||||
self._artwork_generation += 1
|
||||
|
||||
self._stop_pos_fabricator()
|
||||
self._stop_status_polling()
|
||||
|
||||
for signal_id in self._signal_ids:
|
||||
for sid in self._signal_ids:
|
||||
try:
|
||||
self._proxy.disconnect(signal_id)
|
||||
self._proxy.disconnect(sid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error disconnecting signal: {e}")
|
||||
self._signal_ids.clear()
|
||||
|
||||
conn = self._proxy.get_connection()
|
||||
for sid in self._subscription_ids:
|
||||
try:
|
||||
if conn:
|
||||
conn.signal_unsubscribe(sid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error unsubscribing signal: {e}")
|
||||
self._subscription_ids.clear()
|
||||
|
||||
self._current_artwork_path = ""
|
||||
self._failed_covers.clear()
|
||||
self._download_threads.clear()
|
||||
|
||||
@@ -409,8 +409,8 @@ class Weather(Service):
|
||||
return
|
||||
result = raw["results"][0]
|
||||
location_data = {
|
||||
"lat": result["latitude"],
|
||||
"lon": result["longitude"],
|
||||
"lat": round(result["latitude"], 1),
|
||||
"lon": round(result["longitude"], 1),
|
||||
"city": result.get("name", "Unknown"),
|
||||
"country": result.get("country", ""),
|
||||
"config_location": target_loc,
|
||||
@@ -421,8 +421,8 @@ class Weather(Service):
|
||||
GLib.idle_add(self._set_error, "Location unavailable")
|
||||
return
|
||||
location_data = {
|
||||
"lat": raw["lat"],
|
||||
"lon": raw["lon"],
|
||||
"lat": round(raw["lat"], 1),
|
||||
"lon": round(raw["lon"], 1),
|
||||
"city": raw.get("city", "Unknown"),
|
||||
"country": raw.get("country", ""),
|
||||
"config_location": "",
|
||||
@@ -450,6 +450,11 @@ class Weather(Service):
|
||||
GLib.idle_add(self._set_error, "Weather unavailable")
|
||||
return
|
||||
else:
|
||||
weather_data = {
|
||||
k: v
|
||||
for k, v in weather_data.items()
|
||||
if k not in ("latitude", "longitude")
|
||||
}
|
||||
self._weather_cache.set(weather_data)
|
||||
|
||||
GLib.idle_add(self._update_properties, weather_data, location_data)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from fabric.utils import Gdk, GLib
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
class CloseButtonRevealerMixin:
|
||||
_close_button_hide_timeout_id: int | None
|
||||
_hovered: bool
|
||||
_is_close_button_hovered: bool
|
||||
close_button_revealer: Gtk.Revealer
|
||||
|
||||
def _schedule_close_button_hide(self):
|
||||
self._cancel_close_button_hide()
|
||||
self._close_button_hide_timeout_id = GLib.timeout_add(
|
||||
80, self._do_hide_close_button
|
||||
)
|
||||
|
||||
def _cancel_close_button_hide(self):
|
||||
if self._close_button_hide_timeout_id is not None:
|
||||
GLib.source_remove(self._close_button_hide_timeout_id)
|
||||
self._close_button_hide_timeout_id = None
|
||||
|
||||
def _do_hide_close_button(self):
|
||||
self._close_button_hide_timeout_id = None
|
||||
if not self._hovered and not self._is_close_button_hovered:
|
||||
self.close_button_revealer.set_reveal_child(False)
|
||||
return False
|
||||
|
||||
def _on_hover_enter(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
self._hovered = True
|
||||
self._cancel_close_button_hide()
|
||||
self.close_button_revealer.set_reveal_child(True)
|
||||
return False
|
||||
|
||||
def _on_hover_leave(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
self._hovered = False
|
||||
self._schedule_close_button_hide()
|
||||
return False
|
||||
|
||||
def _on_close_button_enter(self, widget, event):
|
||||
self._is_close_button_hovered = True
|
||||
self._cancel_close_button_hide()
|
||||
return False
|
||||
|
||||
def _on_close_button_leave(self, widget, event):
|
||||
self._is_close_button_hovered = False
|
||||
self._schedule_close_button_hide()
|
||||
return False
|
||||
@@ -0,0 +1,188 @@
|
||||
from fabric.utils import bulk_connect, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
|
||||
|
||||
class BasePlayerBoxStack(Box):
|
||||
def __init__(self, mpris_manager, **kwargs):
|
||||
super().__init__(
|
||||
orientation="v", name="media", children=[self.player_stack], **kwargs
|
||||
)
|
||||
self.player_stack.children = [self.no_media_box]
|
||||
self.set_visible(True)
|
||||
self.mpris_manager = mpris_manager
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
if self.mpris_manager is not None:
|
||||
self._init_signals()
|
||||
|
||||
def _init_signals(self):
|
||||
try:
|
||||
connections = bulk_connect(
|
||||
self.mpris_manager,
|
||||
{
|
||||
"new-player": self.on_new_player,
|
||||
"player-vanish": self.on_lost_player,
|
||||
},
|
||||
)
|
||||
for handler_id in connections:
|
||||
self._signal_connections.append((self.mpris_manager, handler_id))
|
||||
|
||||
for name, player in self.mpris_manager.get_all_services().items():
|
||||
self.on_new_player(self.mpris_manager, name, player)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize PlayerBoxStack signals: {e}")
|
||||
|
||||
def _on_map(self, *_):
|
||||
self._check_and_update_playing_state()
|
||||
|
||||
def destroy(self):
|
||||
for obj, handler_id in self._signal_connections:
|
||||
try:
|
||||
obj.disconnect(handler_id)
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
self._signal_connections.clear()
|
||||
super().destroy()
|
||||
|
||||
def _find_playing_player_index(self):
|
||||
players = self.player_stack.get_children()
|
||||
for i, player_box in enumerate(players):
|
||||
if (
|
||||
hasattr(player_box, "player")
|
||||
and str(player_box.player.playback_status).lower() == "playing"
|
||||
):
|
||||
return i
|
||||
return None
|
||||
|
||||
def _check_and_update_playing_state(self):
|
||||
children = self.player_stack.get_children()
|
||||
players = [p for p in children if p != self.no_media_box]
|
||||
if len(players) > 0:
|
||||
if self.no_media_box in children:
|
||||
self.player_stack.remove(self.no_media_box)
|
||||
playing_index = self._find_playing_player_index()
|
||||
if playing_index is not None:
|
||||
self.on_player_clicked_by_index(playing_index)
|
||||
return
|
||||
|
||||
if len(children) == 1 and children[0] == self.no_media_box:
|
||||
return
|
||||
|
||||
self.player_stack.children = [self.no_media_box]
|
||||
self.current_stack_pos = 0
|
||||
self.player_stack.set_visible_child(self.no_media_box)
|
||||
|
||||
def on_player_playback_changed(self, player_box, status):
|
||||
status = str(status).lower()
|
||||
if status == "playing":
|
||||
players = self.player_stack.get_children()
|
||||
for i, pb in enumerate(players):
|
||||
if pb == player_box and i != self.current_stack_pos:
|
||||
self.on_player_clicked_by_index(i)
|
||||
break
|
||||
elif status in ["paused", "stopped"]:
|
||||
self._check_and_update_playing_state()
|
||||
|
||||
def _update_all_player_buttons(self):
|
||||
show_buttons = len(self.player_buttons) > 1
|
||||
for child in self.player_stack.get_children():
|
||||
if hasattr(child, "update_buttons"):
|
||||
child.update_buttons(self.player_buttons, show_buttons)
|
||||
|
||||
def on_player_clicked_by_index(self, index):
|
||||
if 0 <= index < len(self.player_buttons):
|
||||
if self.current_stack_pos < len(self.player_buttons):
|
||||
self.player_buttons[self.current_stack_pos].remove_style_class("active")
|
||||
self.current_stack_pos = index
|
||||
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]
|
||||
)
|
||||
self._update_all_player_buttons()
|
||||
|
||||
def on_new_player(self, mpris_manager, name, player):
|
||||
children = self.player_stack.get_children()
|
||||
if len(children) == 1 and children[0] == self.no_media_box:
|
||||
self.player_stack.children = []
|
||||
self.current_stack_pos = 0
|
||||
|
||||
self.set_visible(True)
|
||||
new_player_box = self._create_player_box_widget(player)
|
||||
self.player_stack.children = [*self.player_stack.children, new_player_box]
|
||||
self.make_new_player_button(new_player_box)
|
||||
if self.player_buttons:
|
||||
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
|
||||
self._check_and_update_playing_state()
|
||||
self._update_all_player_buttons()
|
||||
|
||||
def _create_player_box_widget(self, player):
|
||||
from window.controlcenter.player import PlayerBox
|
||||
|
||||
return PlayerBox(player=player, player_stack=self)
|
||||
|
||||
def on_lost_player(self, mpris_manager, bus_name):
|
||||
children = self.player_stack.get_children()
|
||||
player_box_to_remove = None
|
||||
for player_box in children:
|
||||
if (
|
||||
hasattr(player_box, "player")
|
||||
and getattr(player_box.player, "player_name", None) == bus_name
|
||||
):
|
||||
player_box_to_remove = player_box
|
||||
break
|
||||
|
||||
if player_box_to_remove:
|
||||
player_box_to_remove.destroy()
|
||||
|
||||
remaining = [
|
||||
c for c in self.player_stack.get_children() if c != player_box_to_remove
|
||||
]
|
||||
if len(remaining) == 0:
|
||||
self.player_stack.children = [self.no_media_box]
|
||||
self.current_stack_pos = 0
|
||||
self.player_buttons = []
|
||||
return
|
||||
|
||||
if self.current_stack_pos >= len(remaining):
|
||||
self.current_stack_pos = max(0, len(remaining) - 1)
|
||||
|
||||
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
|
||||
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
|
||||
self.player_stack.set_visible_child(remaining[self.current_stack_pos])
|
||||
self._update_all_player_buttons()
|
||||
|
||||
def make_new_player_button(self, player_box):
|
||||
new_button = Button(name="player-stack-button")
|
||||
|
||||
def on_player_button_click(button: Button):
|
||||
if self.current_stack_pos < len(self.player_buttons):
|
||||
self.player_buttons[self.current_stack_pos].remove_style_class("active")
|
||||
if button in self.player_buttons:
|
||||
self.current_stack_pos = self.player_buttons.index(button)
|
||||
button.add_style_class("active")
|
||||
self.player_stack.set_visible_child(player_box)
|
||||
|
||||
new_button.connect("clicked", on_player_button_click)
|
||||
self.player_buttons.append(new_button)
|
||||
player_box.connect(
|
||||
"destroy",
|
||||
lambda *_: (
|
||||
self.player_buttons.remove(new_button)
|
||||
if new_button in self.player_buttons
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def suspend(self):
|
||||
for child in self.player_stack.get_children():
|
||||
if hasattr(child, "suspend"):
|
||||
child.suspend()
|
||||
|
||||
def resume(self):
|
||||
for child in self.player_stack.get_children():
|
||||
if hasattr(child, "resume"):
|
||||
child.resume()
|
||||
|
||||
def _create_no_media_box(self):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,100 @@
|
||||
from fabric.utils import Gdk, logger
|
||||
|
||||
|
||||
class PullToRefreshMixin:
|
||||
def setup_pull_to_refresh(self, scrolled_widget):
|
||||
self._scrolled_widget = scrolled_widget
|
||||
self.vadjustment = scrolled_widget.get_vadjustment()
|
||||
self.pull_start_y = 0
|
||||
self.is_pulling = False
|
||||
self.pull_threshold = 50
|
||||
self._anim_finished_handler = None
|
||||
|
||||
scrolled_widget.connect("scroll-event", self.on_scroll_event)
|
||||
scrolled_widget.connect("button-press-event", self.on_button_press)
|
||||
scrolled_widget.connect("button-release-event", self.on_button_release)
|
||||
scrolled_widget.connect("motion-notify-event", self.on_motion_notify)
|
||||
|
||||
scrolled_widget.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):
|
||||
if self.vadjustment.get_value() <= 0:
|
||||
if event.direction == Gdk.ScrollDirection.UP:
|
||||
self._trigger_scan()
|
||||
self._trigger_refresh()
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_button_press(self, widget, event):
|
||||
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):
|
||||
if self.is_pulling:
|
||||
pull_distance = event.y - self.pull_start_y
|
||||
if pull_distance > self.pull_threshold:
|
||||
self._trigger_scan()
|
||||
self._trigger_refresh()
|
||||
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):
|
||||
if self.is_pulling and self.vadjustment.get_value() <= 0:
|
||||
pull_distance = event.y - self.pull_start_y
|
||||
if pull_distance > 0:
|
||||
self.refresh_indicator.set_visible(True)
|
||||
if pull_distance >= self.pull_threshold:
|
||||
self.refresh_indicator.set_label(self._get_pull_ready_label())
|
||||
self.refresh_indicator.add_style_class("ready-to-refresh")
|
||||
else:
|
||||
self.refresh_indicator.set_label(self._get_pull_normal_label())
|
||||
self.refresh_indicator.remove_style_class("ready-to-refresh")
|
||||
else:
|
||||
self.refresh_indicator.set_visible(False)
|
||||
return False
|
||||
|
||||
def _cancel_pending_refresh(self):
|
||||
if hasattr(self, "_anim_finished_handler") and self._anim_finished_handler:
|
||||
try:
|
||||
self._scrolled_widget.height_animator.disconnect(
|
||||
self._anim_finished_handler
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[pull-to-refresh] height_animator.disconnect(...) failed: {e}"
|
||||
)
|
||||
self._anim_finished_handler = None
|
||||
|
||||
def _refresh_after_animation(self):
|
||||
if self._destroyed:
|
||||
return False
|
||||
anim = self._scrolled_widget.height_animator
|
||||
if anim.playing:
|
||||
self._anim_finished_handler = anim.connect(
|
||||
"finished",
|
||||
lambda *_: self._trigger_refresh() if not self._destroyed else None,
|
||||
)
|
||||
else:
|
||||
self._trigger_refresh()
|
||||
return False
|
||||
|
||||
def _get_pull_normal_label(self):
|
||||
return "↓ Pull to refresh"
|
||||
|
||||
def _get_pull_ready_label(self):
|
||||
return "↑ Release to refresh"
|
||||
|
||||
def _trigger_scan(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _trigger_refresh(self):
|
||||
raise NotImplementedError
|
||||
@@ -1,6 +1,6 @@
|
||||
from enum import Enum, auto
|
||||
|
||||
from fabric.utils import Gdk, GLib, Gtk, idle_add, logger
|
||||
from fabric.utils import GLib, Gtk, idle_add, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
@@ -9,6 +9,7 @@ from fabric.widgets.label import Label
|
||||
from fabric.widgets.separator import Separator
|
||||
|
||||
from services.bluetooth import BluetoothClient, BluetoothDevice
|
||||
from shared.widgets.pull_to_refresh import PullToRefreshMixin
|
||||
from shared.widgets.smooth_switch import SmoothSwitch
|
||||
from shared.window.animated_scrollwindow import AnimatedScrollable
|
||||
from utils.functions import spawn_detached
|
||||
@@ -304,7 +305,7 @@ class BluetoothDeviceSlot(Box):
|
||||
logger.warning(f"[Bluetooth] Failed to remove device: {e}")
|
||||
|
||||
|
||||
class BluetoothConnections(Box):
|
||||
class BluetoothConnections(PullToRefreshMixin, Box):
|
||||
def __init__(
|
||||
self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs
|
||||
):
|
||||
@@ -422,7 +423,7 @@ class BluetoothConnections(Box):
|
||||
self.other_devices.set_visible(False)
|
||||
|
||||
# Add pull-to-refresh functionality to scrolled window
|
||||
self.setup_pull_to_refresh()
|
||||
self.setup_pull_to_refresh(self.other_devices_scrolled)
|
||||
|
||||
self.more_settings_button = Button(
|
||||
child=Label("More Settings", h_align="start"),
|
||||
@@ -507,31 +508,6 @@ class BluetoothConnections(Box):
|
||||
# Defer refresh until after the height animation completes
|
||||
idle_add(self._refresh_after_animation)
|
||||
|
||||
def _cancel_pending_refresh(self):
|
||||
if hasattr(self, "_anim_finished_handler") and self._anim_finished_handler:
|
||||
try:
|
||||
self.other_devices_scrolled.height_animator.disconnect(
|
||||
self._anim_finished_handler
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[bluetooth] self.other_devices_scrolled.height_animator.disconnect( s... failed: {e}"
|
||||
)
|
||||
self._anim_finished_handler = None
|
||||
|
||||
def _refresh_after_animation(self):
|
||||
if self._destroyed:
|
||||
return False
|
||||
anim = self.other_devices_scrolled.height_animator
|
||||
if anim.playing:
|
||||
self._anim_finished_handler = anim.connect(
|
||||
"finished",
|
||||
lambda *_: self.force_device_refresh() if not self._destroyed else None,
|
||||
)
|
||||
else:
|
||||
self.force_device_refresh()
|
||||
return False
|
||||
|
||||
def open_bluetooth_settings(self, *_):
|
||||
"""Open Blueman bluetooth manager"""
|
||||
try:
|
||||
@@ -692,84 +668,22 @@ class BluetoothConnections(Box):
|
||||
self.other_devices.set_visible(False)
|
||||
self.other_devices_scrolled.snap_to_size(0)
|
||||
|
||||
def setup_pull_to_refresh(self):
|
||||
"""Setup pull-to-refresh gesture for the scrolled window"""
|
||||
self.vadjustment = self.other_devices_scrolled.get_vadjustment()
|
||||
def _trigger_scan(self):
|
||||
if not self.client.scanning and hasattr(self.client, "scan"):
|
||||
self.client.scan()
|
||||
|
||||
# Track gesture state
|
||||
self.pull_start_y = 0
|
||||
self.is_pulling = False
|
||||
self.pull_threshold = 50 # pixels to trigger refresh
|
||||
def _trigger_refresh(self):
|
||||
self.force_device_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
|
||||
)
|
||||
def _get_pull_normal_label(self):
|
||||
if self.client.scanning:
|
||||
return "↓ Pull to stop scanning"
|
||||
return "↓ Pull to scan for devices"
|
||||
|
||||
# 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"""
|
||||
if self.vadjustment.get_value() <= 0:
|
||||
if event.direction == Gdk.ScrollDirection.UP:
|
||||
if not self.client.scanning and hasattr(self.client, "scan"):
|
||||
self.client.scan()
|
||||
self.force_device_refresh()
|
||||
return True
|
||||
return False
|
||||
|
||||
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:
|
||||
if not self.client.scanning and hasattr(self.client, "scan"):
|
||||
self.client.scan()
|
||||
self.force_device_refresh()
|
||||
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:
|
||||
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 _get_pull_ready_label(self):
|
||||
if self.client.scanning:
|
||||
return "↑ Release to stop scanning"
|
||||
return "↑ Release to scan"
|
||||
|
||||
def on_device_added(self, client: BluetoothClient, address: str):
|
||||
"""Handle when a new device is added"""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import weakref
|
||||
|
||||
from fabric.utils import GLib, bulk_connect, logger
|
||||
from fabric.widgets.box import Box
|
||||
@@ -12,11 +11,13 @@ from fabric.widgets.stack import Stack
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
|
||||
from services.mpris import PlayerManager, PlayerService
|
||||
from shared.widgets.player_box_stack import BasePlayerBoxStack
|
||||
from utils.gtk_utils import svg_file
|
||||
from window.controlcenter.player import (
|
||||
PLAYER_FALLBACK_ART,
|
||||
apply_player_art,
|
||||
get_shared_mpris_manager,
|
||||
resolve_mpris_player_icon,
|
||||
)
|
||||
|
||||
|
||||
@@ -55,42 +56,18 @@ class EmbeddedExpandedPlayer(Box):
|
||||
super().destroy()
|
||||
|
||||
|
||||
class PlayerBoxStack(Box):
|
||||
class PlayerBoxStack(BasePlayerBoxStack):
|
||||
def __init__(self, mpris_manager: PlayerManager, **kwargs):
|
||||
self.player_stack = Stack(name="player-stack", transition_type="none")
|
||||
self.current_stack_pos = 0
|
||||
self.player_buttons: list[Button] = []
|
||||
self._player_widgets = weakref.WeakValueDictionary()
|
||||
self._signal_connections = []
|
||||
self.no_media_box = self._create_no_media_box()
|
||||
|
||||
super().__init__(orientation="v", name="media", children=[self.player_stack])
|
||||
self.player_stack.children = [self.no_media_box]
|
||||
self.set_visible(True)
|
||||
self.mpris_manager = mpris_manager
|
||||
self.connect("map", self._on_map)
|
||||
super().__init__(mpris_manager, **kwargs)
|
||||
|
||||
connections = bulk_connect(
|
||||
self.mpris_manager,
|
||||
{"new-player": self.on_new_player, "player-vanish": self.on_lost_player},
|
||||
)
|
||||
for handler_id in connections:
|
||||
self._signal_connections.append((self.mpris_manager, handler_id))
|
||||
|
||||
for name, player in self.mpris_manager.get_all_services().items():
|
||||
self.on_new_player(self.mpris_manager, name, player)
|
||||
|
||||
def _on_map(self, *_):
|
||||
self._check_and_update_playing_state()
|
||||
|
||||
def destroy(self):
|
||||
for obj, handler_id in self._signal_connections:
|
||||
try:
|
||||
obj.disconnect(handler_id)
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
self._signal_connections.clear()
|
||||
super().destroy()
|
||||
def _create_player_box_widget(self, player):
|
||||
return PlayerBox(player=player, player_stack=self)
|
||||
|
||||
def _create_no_media_box(self):
|
||||
fallback_cover_path = PLAYER_FALLBACK_ART
|
||||
@@ -139,138 +116,6 @@ class PlayerBoxStack(Box):
|
||||
],
|
||||
)
|
||||
|
||||
def _find_playing_player_index(self):
|
||||
players = self.player_stack.get_children()
|
||||
for i, player_box in enumerate(players):
|
||||
if (
|
||||
hasattr(player_box, "player")
|
||||
and str(player_box.player.playback_status).lower() == "playing"
|
||||
):
|
||||
return i
|
||||
return None
|
||||
|
||||
def _check_and_update_playing_state(self):
|
||||
players = [
|
||||
p for p in self.player_stack.get_children() if p != self.no_media_box
|
||||
]
|
||||
if len(players) > 0:
|
||||
if self.no_media_box in self.player_stack.get_children():
|
||||
self.player_stack.remove(self.no_media_box)
|
||||
playing_index = self._find_playing_player_index()
|
||||
if playing_index is not None:
|
||||
self.on_player_clicked_by_index(playing_index)
|
||||
return
|
||||
|
||||
if (
|
||||
len(self.player_stack.get_children()) == 1
|
||||
and self.player_stack.get_children()[0] == self.no_media_box
|
||||
):
|
||||
return
|
||||
|
||||
self.player_stack.children = [self.no_media_box]
|
||||
self.current_stack_pos = 0
|
||||
self.player_stack.set_visible_child(self.no_media_box)
|
||||
|
||||
def on_player_playback_changed(self, player_box, status):
|
||||
status = str(status).lower()
|
||||
if status == "playing":
|
||||
players = self.player_stack.get_children()
|
||||
for i, pb in enumerate(players):
|
||||
if pb == player_box and i != self.current_stack_pos:
|
||||
self.on_player_clicked_by_index(i)
|
||||
break
|
||||
elif status in ["paused", "stopped"]:
|
||||
self._check_and_update_playing_state()
|
||||
|
||||
def on_player_clicked_by_index(self, index):
|
||||
if 0 <= index < len(self.player_buttons):
|
||||
if self.current_stack_pos < len(self.player_buttons):
|
||||
self.player_buttons[self.current_stack_pos].remove_style_class("active")
|
||||
self.current_stack_pos = index
|
||||
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]
|
||||
)
|
||||
self._update_all_player_buttons()
|
||||
|
||||
def on_new_player(self, mpris_manager, name, player):
|
||||
if (
|
||||
len(self.player_stack.get_children()) == 1
|
||||
and self.player_stack.get_children()[0] == self.no_media_box
|
||||
):
|
||||
self.player_stack.children = []
|
||||
self.current_stack_pos = 0
|
||||
|
||||
self.set_visible(True)
|
||||
new_player_box = PlayerBox(player=player, player_stack=self)
|
||||
self.player_stack.children = [*self.player_stack.children, new_player_box]
|
||||
self.make_new_player_button(new_player_box)
|
||||
if self.player_buttons:
|
||||
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
|
||||
self._check_and_update_playing_state()
|
||||
self._update_all_player_buttons()
|
||||
|
||||
def on_lost_player(self, mpris_manager, player_name):
|
||||
player_box_to_remove = None
|
||||
for player_box in self.player_stack.get_children():
|
||||
if (
|
||||
hasattr(player_box, "player")
|
||||
and player_box.player.player_name == player_name
|
||||
):
|
||||
player_box_to_remove = player_box
|
||||
break
|
||||
|
||||
if player_box_to_remove:
|
||||
player_box_to_remove.destroy()
|
||||
|
||||
remaining_players = [
|
||||
p for p in self.player_stack.get_children() if p != player_box_to_remove
|
||||
]
|
||||
if len(remaining_players) == 0:
|
||||
self.player_stack.children = [self.no_media_box]
|
||||
self.current_stack_pos = 0
|
||||
self.player_buttons = []
|
||||
return
|
||||
|
||||
if self.current_stack_pos >= len(self.player_stack.get_children()):
|
||||
self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)
|
||||
|
||||
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
|
||||
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
|
||||
self.player_stack.set_visible_child(
|
||||
self.player_stack.get_children()[self.current_stack_pos]
|
||||
)
|
||||
|
||||
self._update_all_player_buttons()
|
||||
|
||||
def make_new_player_button(self, player_box):
|
||||
new_button = Button(name="player-stack-button")
|
||||
|
||||
def on_player_button_click(button: Button):
|
||||
if self.current_stack_pos < len(self.player_buttons):
|
||||
self.player_buttons[self.current_stack_pos].remove_style_class("active")
|
||||
if button in self.player_buttons:
|
||||
self.current_stack_pos = self.player_buttons.index(button)
|
||||
button.add_style_class("active")
|
||||
self.player_stack.set_visible_child(player_box)
|
||||
|
||||
new_button.connect("clicked", on_player_button_click)
|
||||
self.player_buttons.append(new_button)
|
||||
player_box.connect(
|
||||
"destroy",
|
||||
lambda *_: (
|
||||
self.player_buttons.remove(new_button)
|
||||
if new_button in self.player_buttons
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def _update_all_player_buttons(self):
|
||||
players = self.player_stack.get_children()
|
||||
for player_box in players:
|
||||
if hasattr(player_box, "update_buttons"):
|
||||
player_box.update_buttons(self.player_buttons, len(players) > 1)
|
||||
|
||||
|
||||
class PlayerBox(Box):
|
||||
def __init__(self, player: PlayerService, player_stack=None, **kwargs):
|
||||
@@ -303,7 +148,7 @@ class PlayerBox(Box):
|
||||
overlays=[
|
||||
Box(
|
||||
children=Image(
|
||||
icon_name=self.player.player_name,
|
||||
icon_name=resolve_mpris_player_icon(self.player.player_name),
|
||||
name="player-app-icon",
|
||||
icon_size=20,
|
||||
),
|
||||
|
||||
@@ -9,16 +9,60 @@ from fabric.widgets.stack import Stack
|
||||
|
||||
import shared.data as data
|
||||
from services.mpris import PlayerManager, PlayerService
|
||||
from shared.widgets.player_box_stack import BasePlayerBoxStack
|
||||
from utils.gtk_utils import svg_file
|
||||
|
||||
CACHE_DIR = f"{data.CACHE_DIR}/media"
|
||||
PLAYER_FALLBACK_ART = get_relative_path("../../assets/icons/music.svg")
|
||||
MPRIS_ICON_FALLBACK = "application-x-executable-symbolic"
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
os.makedirs(CACHE_DIR)
|
||||
|
||||
_shared_mpris_manager = None
|
||||
|
||||
|
||||
def resolve_mpris_player_icon(player_name: str) -> str:
|
||||
"""Resolve MPRIS player icon from .desktop file."""
|
||||
raw = player_name or ""
|
||||
app_name = raw.removeprefix("org.mpris.MediaPlayer2.")
|
||||
|
||||
search_dirs = [
|
||||
os.path.join(os.path.expanduser("~/.local/share"), "applications"),
|
||||
"/usr/share/applications",
|
||||
"/usr/local/share/applications",
|
||||
]
|
||||
for d in os.environ.get("XDG_DATA_DIRS", "/usr/share:/usr/local/share").split(":"):
|
||||
p = os.path.join(d, "applications")
|
||||
if p not in search_dirs:
|
||||
search_dirs.append(p)
|
||||
|
||||
candidates = [app_name]
|
||||
parts = app_name.split("-")
|
||||
for i in range(len(parts) - 1, 0, -1):
|
||||
shorter = "-".join(parts[:i])
|
||||
if shorter not in candidates:
|
||||
candidates.append(shorter)
|
||||
|
||||
for base in search_dirs:
|
||||
for name in candidates:
|
||||
dp = os.path.join(base, name + ".desktop")
|
||||
if os.path.exists(dp):
|
||||
try:
|
||||
with open(dp) as f:
|
||||
for line in f:
|
||||
if line.startswith("Icon="):
|
||||
return line[5:].strip()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# No desktop file found — try app_name as direct icon name
|
||||
for name in candidates:
|
||||
if name:
|
||||
return name
|
||||
|
||||
return MPRIS_ICON_FALLBACK
|
||||
|
||||
|
||||
def get_shared_mpris_manager():
|
||||
global _shared_mpris_manager
|
||||
if _shared_mpris_manager is None:
|
||||
@@ -61,7 +105,7 @@ def apply_player_art(album_cover_widget, player, service=None, path=None):
|
||||
return None
|
||||
|
||||
|
||||
class PlayerBoxStack(Box):
|
||||
class PlayerBoxStack(BasePlayerBoxStack):
|
||||
def __init__(
|
||||
self, mpris_manager: PlayerManager = None, control_center=None, **kwargs
|
||||
):
|
||||
@@ -72,53 +116,12 @@ class PlayerBoxStack(Box):
|
||||
self._signal_connections = []
|
||||
self.no_media_box = self._create_no_media_box()
|
||||
|
||||
super().__init__(orientation="v", name="media", children=[self.player_stack])
|
||||
self.player_stack.children = [self.no_media_box]
|
||||
self.set_visible(True)
|
||||
self.mpris_manager = mpris_manager or get_shared_mpris_manager()
|
||||
self.connect("map", self._on_map)
|
||||
super().__init__(mpris_manager or get_shared_mpris_manager(), **kwargs)
|
||||
|
||||
if self.mpris_manager is not None:
|
||||
self._init_mpris_manager()
|
||||
|
||||
def _init_mpris_manager(self):
|
||||
try:
|
||||
connections = bulk_connect(
|
||||
self.mpris_manager,
|
||||
{
|
||||
"new-player": self.on_new_player,
|
||||
"player-vanish": self.on_lost_player,
|
||||
},
|
||||
)
|
||||
for handler_id in connections:
|
||||
self._signal_connections.append((self.mpris_manager, handler_id))
|
||||
|
||||
for name, player in self.mpris_manager.get_all_services().items():
|
||||
self.on_new_player(self.mpris_manager, name, player)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize PlayerBoxStack signals: {e}")
|
||||
|
||||
def _on_map(self, *_):
|
||||
self._check_and_update_playing_state()
|
||||
|
||||
def destroy(self):
|
||||
for obj, handler_id in self._signal_connections:
|
||||
try:
|
||||
obj.disconnect(handler_id)
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
self._signal_connections.clear()
|
||||
super().destroy()
|
||||
|
||||
def suspend(self):
|
||||
for child in self.player_stack.get_children():
|
||||
if hasattr(child, "suspend"):
|
||||
child.suspend()
|
||||
|
||||
def resume(self):
|
||||
for child in self.player_stack.get_children():
|
||||
if hasattr(child, "resume"):
|
||||
child.resume()
|
||||
def _create_player_box_widget(self, player):
|
||||
return PlayerBox(
|
||||
player=player, player_stack=self, control_center=self.control_center
|
||||
)
|
||||
|
||||
def _create_no_media_box(self):
|
||||
fallback_cover_path = get_relative_path("../../assets/icons/music.svg")
|
||||
@@ -172,138 +175,6 @@ class PlayerBoxStack(Box):
|
||||
],
|
||||
)
|
||||
|
||||
def _find_playing_player_index(self):
|
||||
players = self.player_stack.get_children()
|
||||
for i, player_box in enumerate(players):
|
||||
if (
|
||||
hasattr(player_box, "player")
|
||||
and str(player_box.player.playback_status).lower() == "playing"
|
||||
):
|
||||
return i
|
||||
return None
|
||||
|
||||
def _check_and_update_playing_state(self):
|
||||
players = [
|
||||
p for p in self.player_stack.get_children() if p != self.no_media_box
|
||||
]
|
||||
if len(players) > 0:
|
||||
if self.no_media_box in self.player_stack.get_children():
|
||||
self.player_stack.remove(self.no_media_box)
|
||||
playing_index = self._find_playing_player_index()
|
||||
if playing_index is not None:
|
||||
self.on_player_clicked_by_index(playing_index)
|
||||
return
|
||||
|
||||
if (
|
||||
len(self.player_stack.get_children()) == 1
|
||||
and self.player_stack.get_children()[0] == self.no_media_box
|
||||
):
|
||||
return
|
||||
|
||||
self.player_stack.children = [self.no_media_box]
|
||||
self.current_stack_pos = 0
|
||||
self.player_stack.set_visible_child(self.no_media_box)
|
||||
|
||||
def on_player_playback_changed(self, player_box, status):
|
||||
status = str(status).lower()
|
||||
if status == "playing":
|
||||
players = self.player_stack.get_children()
|
||||
for i, pb in enumerate(players):
|
||||
if pb == player_box and i != self.current_stack_pos:
|
||||
self.on_player_clicked_by_index(i)
|
||||
break
|
||||
elif status in ["paused", "stopped"]:
|
||||
self._check_and_update_playing_state()
|
||||
|
||||
def _update_all_player_buttons(self):
|
||||
show_buttons = len(self.player_buttons) > 1
|
||||
for child in self.player_stack.get_children():
|
||||
if hasattr(child, "update_buttons"):
|
||||
child.update_buttons(self.player_buttons, show_buttons)
|
||||
|
||||
def on_player_clicked_by_index(self, index):
|
||||
if 0 <= index < len(self.player_buttons):
|
||||
if self.current_stack_pos < len(self.player_buttons):
|
||||
self.player_buttons[self.current_stack_pos].remove_style_class("active")
|
||||
self.current_stack_pos = index
|
||||
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]
|
||||
)
|
||||
self._update_all_player_buttons()
|
||||
|
||||
def on_new_player(self, mpris_manager, name, player):
|
||||
if (
|
||||
len(self.player_stack.get_children()) == 1
|
||||
and self.player_stack.get_children()[0] == self.no_media_box
|
||||
):
|
||||
self.player_stack.children = []
|
||||
self.current_stack_pos = 0
|
||||
|
||||
new_player_box = PlayerBox(
|
||||
player=player, player_stack=self, control_center=self.control_center
|
||||
)
|
||||
self.player_stack.children = [*self.player_stack.children, new_player_box]
|
||||
self.make_new_player_button(new_player_box)
|
||||
if self.player_buttons:
|
||||
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
|
||||
self._check_and_update_playing_state()
|
||||
self._update_all_player_buttons()
|
||||
|
||||
def on_lost_player(self, mpris_manager, bus_name):
|
||||
player_box_to_remove = None
|
||||
for player_box in self.player_stack.get_children():
|
||||
if (
|
||||
hasattr(player_box, "player")
|
||||
and getattr(player_box.player, "player_name", None) == bus_name
|
||||
):
|
||||
player_box_to_remove = player_box
|
||||
break
|
||||
|
||||
if player_box_to_remove:
|
||||
player_box_to_remove.destroy()
|
||||
|
||||
remaining_players = [
|
||||
p for p in self.player_stack.get_children() if p != player_box_to_remove
|
||||
]
|
||||
if len(remaining_players) == 0:
|
||||
self.player_stack.children = [self.no_media_box]
|
||||
self.current_stack_pos = 0
|
||||
self.player_buttons = []
|
||||
return
|
||||
|
||||
if self.current_stack_pos >= len(self.player_stack.get_children()):
|
||||
self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)
|
||||
|
||||
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
|
||||
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
|
||||
self.player_stack.set_visible_child(
|
||||
self.player_stack.get_children()[self.current_stack_pos]
|
||||
)
|
||||
self._update_all_player_buttons()
|
||||
|
||||
def make_new_player_button(self, player_box):
|
||||
new_button = Button(name="player-stack-button")
|
||||
|
||||
def on_player_button_click(button: Button):
|
||||
if self.current_stack_pos < len(self.player_buttons):
|
||||
self.player_buttons[self.current_stack_pos].remove_style_class("active")
|
||||
if button in self.player_buttons:
|
||||
self.current_stack_pos = self.player_buttons.index(button)
|
||||
button.add_style_class("active")
|
||||
self.player_stack.set_visible_child(player_box)
|
||||
|
||||
new_button.connect("clicked", on_player_button_click)
|
||||
self.player_buttons.append(new_button)
|
||||
player_box.connect(
|
||||
"destroy",
|
||||
lambda *_: (
|
||||
self.player_buttons.remove(new_button)
|
||||
if new_button in self.player_buttons
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PlayerBox(Box):
|
||||
def __init__(
|
||||
@@ -327,7 +198,7 @@ class PlayerBox(Box):
|
||||
overlays=[
|
||||
Box(
|
||||
children=Image(
|
||||
icon_name=self.player.player_name,
|
||||
icon_name=resolve_mpris_player_icon(self.player.player_name),
|
||||
name="player-app-icon",
|
||||
icon_size=20,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fabric.utils import Gdk, GLib, idle_add, logger
|
||||
from fabric.utils import GLib, idle_add, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
@@ -9,6 +9,7 @@ from fabric.widgets.separator import Separator
|
||||
from services import dns as dns_service
|
||||
from services.network import NetworkClient
|
||||
from shared.dialogs.sysauth_dialog import run_auth_dialog
|
||||
from shared.widgets.pull_to_refresh import PullToRefreshMixin
|
||||
from shared.widgets.smooth_switch import SmoothSwitch
|
||||
from shared.window.animated_scrollwindow import AnimatedScrollable
|
||||
from utils.functions import (
|
||||
@@ -402,7 +403,7 @@ class DnsSwitcher:
|
||||
self._load_current_dns()
|
||||
|
||||
|
||||
class WifiConnections(Box):
|
||||
class WifiConnections(PullToRefreshMixin, Box):
|
||||
def __init__(self, parent, show_back_button=True, network_service=None, **kwargs):
|
||||
super().__init__(
|
||||
spacing=8,
|
||||
@@ -506,7 +507,7 @@ class WifiConnections(Box):
|
||||
self.other_networks.set_visible(False)
|
||||
|
||||
# Add pull-to-refresh functionality to scrolled window
|
||||
self.setup_pull_to_refresh()
|
||||
self.setup_pull_to_refresh(self.other_networks_scrolled)
|
||||
|
||||
# Create More Settings button (same style as Other Networks button)
|
||||
self.more_settings_button = Button(
|
||||
@@ -574,33 +575,6 @@ class WifiConnections(Box):
|
||||
# Defer refresh until after the height animation completes
|
||||
idle_add(self._refresh_after_animation)
|
||||
|
||||
def _cancel_pending_refresh(self):
|
||||
if hasattr(self, "_anim_finished_handler") and self._anim_finished_handler:
|
||||
try:
|
||||
self.other_networks_scrolled.height_animator.disconnect(
|
||||
self._anim_finished_handler
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[wifi] self.other_networks_scrolled.height_animator.disconnect( ... failed: {e}"
|
||||
)
|
||||
self._anim_finished_handler = None
|
||||
|
||||
def _refresh_after_animation(self):
|
||||
if self._destroyed:
|
||||
return False
|
||||
anim = self.other_networks_scrolled.height_animator
|
||||
if anim.playing:
|
||||
self._anim_finished_handler = anim.connect(
|
||||
"finished",
|
||||
lambda *_: (
|
||||
self.force_network_refresh() if not self._destroyed else None
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.force_network_refresh()
|
||||
return False
|
||||
|
||||
def on_network_ready(self, *_):
|
||||
"""Called when network service is ready"""
|
||||
if self._network_ready_fired and self.wifi_service:
|
||||
@@ -812,78 +786,18 @@ class WifiConnections(Box):
|
||||
except Exception as e:
|
||||
logger.error(f"[WiFi] Error during forced network refresh: {e}")
|
||||
|
||||
def setup_pull_to_refresh(self):
|
||||
"""Setup pull-to-refresh gesture for the scrolled window"""
|
||||
self.vadjustment = self.other_networks_scrolled.get_vadjustment()
|
||||
def _trigger_scan(self):
|
||||
if self.wifi_service:
|
||||
self.wifi_service.scan()
|
||||
|
||||
# Track gesture state
|
||||
self.pull_start_y = 0
|
||||
self.is_pulling = False
|
||||
self.pull_threshold = 50 # pixels to trigger refresh
|
||||
def _trigger_refresh(self):
|
||||
self.force_network_refresh()
|
||||
|
||||
# Connect to scroll events
|
||||
self.other_networks_scrolled.connect("scroll-event", self.on_scroll_event)
|
||||
self.other_networks_scrolled.connect("button-press-event", self.on_button_press)
|
||||
self.other_networks_scrolled.connect(
|
||||
"button-release-event", self.on_button_release
|
||||
)
|
||||
self.other_networks_scrolled.connect(
|
||||
"motion-notify-event", self.on_motion_notify
|
||||
)
|
||||
def _get_pull_normal_label(self):
|
||||
return "↓ Pull to scan for networks"
|
||||
|
||||
# Enable events
|
||||
self.other_networks_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"""
|
||||
if self.vadjustment.get_value() <= 0:
|
||||
if event.direction == Gdk.ScrollDirection.UP:
|
||||
if self.wifi_service:
|
||||
self.wifi_service.scan()
|
||||
self.force_network_refresh()
|
||||
return True
|
||||
return False
|
||||
|
||||
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:
|
||||
if self.wifi_service:
|
||||
self.wifi_service.scan()
|
||||
self.force_network_refresh()
|
||||
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:
|
||||
self.refresh_indicator.set_visible(True)
|
||||
if pull_distance >= self.pull_threshold:
|
||||
self.refresh_indicator.set_label("↑ Release to scan")
|
||||
self.refresh_indicator.add_style_class("ready-to-refresh")
|
||||
else:
|
||||
self.refresh_indicator.set_label("↓ Pull to scan for networks")
|
||||
self.refresh_indicator.remove_style_class("ready-to-refresh")
|
||||
else:
|
||||
self.refresh_indicator.set_visible(False)
|
||||
return False
|
||||
def _get_pull_ready_label(self):
|
||||
return "↑ Release to scan"
|
||||
|
||||
def on_destroy(self, widget):
|
||||
"""Cleanup when widget is destroyed"""
|
||||
|
||||
@@ -194,8 +194,6 @@ class DesktopWidgetWindow(WaylandWindow):
|
||||
self.connect("button-press-event", self._on_window_button_press)
|
||||
self._setup_drag_dest()
|
||||
self._setup_file_monitor()
|
||||
self._last_user_files: set[str] = set()
|
||||
GLib.timeout_add(2000, self._poll_user_widgets)
|
||||
|
||||
GLib.timeout_add(50, self._initial_build)
|
||||
|
||||
@@ -251,31 +249,6 @@ class DesktopWidgetWindow(WaylandWindow):
|
||||
|
||||
# fallback poll — catches deletions that Gio file monitor misses
|
||||
|
||||
def _poll_user_widgets(self) -> bool:
|
||||
if not os.path.isdir(_CONFIG_DESKTOP_DIR):
|
||||
return True
|
||||
current = {
|
||||
f[:-3]
|
||||
for f in os.listdir(_CONFIG_DESKTOP_DIR)
|
||||
if f.endswith(".py") and not f.startswith("_")
|
||||
}
|
||||
if current != self._last_user_files:
|
||||
self._last_user_files = current
|
||||
stale = {k for k, f in _user_key_to_file.items() if f not in current}
|
||||
if stale:
|
||||
logger.info(f"[DesktopWidgets] poll found stale: {stale}")
|
||||
for key in stale:
|
||||
mod_name = _user_key_to_file.pop(key, None)
|
||||
if mod_name:
|
||||
sys.modules.pop(f"user_widget_{mod_name}", None)
|
||||
_user_widget_modules.pop(mod_name, None)
|
||||
DesktopWidgetRegistry.unregister_user_keys(stale)
|
||||
_load_user_widgets()
|
||||
self.rebuild()
|
||||
return True
|
||||
|
||||
# file monitor — live-rebuild when config/desktop/ changes
|
||||
|
||||
def _setup_file_monitor(self) -> None:
|
||||
if not os.path.isdir(_CONFIG_DESKTOP_DIR):
|
||||
os.makedirs(_CONFIG_DESKTOP_DIR, exist_ok=True)
|
||||
|
||||
@@ -19,6 +19,7 @@ import shared.data as data
|
||||
from services.config import get_config, on_config_change
|
||||
from services.modus import notification_service
|
||||
from shared.widgets.clipping_box import ClippingBox
|
||||
from shared.widgets.close_button_revealer import CloseButtonRevealerMixin
|
||||
from shared.widgets.custom_image import CustomImage
|
||||
from shared.widgets.customrevealer import SlideRevealer
|
||||
from utils.gtk_utils import setup_cursor_hover, svg_file
|
||||
@@ -847,7 +848,7 @@ class NotificationWidget(Box):
|
||||
return data.NOTIFICATION_TIMEOUT
|
||||
|
||||
|
||||
class NotificationRevealer(SlideRevealer):
|
||||
class NotificationRevealer(CloseButtonRevealerMixin, SlideRevealer):
|
||||
def __init__(
|
||||
self,
|
||||
notification: Notification,
|
||||
@@ -872,7 +873,7 @@ class NotificationRevealer(SlideRevealer):
|
||||
setup_cursor_hover(self.close_button)
|
||||
|
||||
self._is_close_button_hovered = False
|
||||
self._is_popup_hovered = False
|
||||
self._hovered = False
|
||||
self._close_button_hide_timeout_id = None
|
||||
|
||||
self.close_button_event_box = EventBox(
|
||||
@@ -927,8 +928,8 @@ class NotificationRevealer(SlideRevealer):
|
||||
],
|
||||
child=self.notif_box,
|
||||
)
|
||||
self.event_box.connect("enter-notify-event", self._on_popup_enter)
|
||||
self.event_box.connect("leave-notify-event", self._on_popup_leave)
|
||||
self.event_box.connect("enter-notify-event", self._on_hover_enter)
|
||||
self.event_box.connect("leave-notify-event", self._on_hover_leave)
|
||||
self.event_box.connect("button-press-event", self._on_button_press)
|
||||
self.event_box.connect("button-release-event", self._on_button_release)
|
||||
self.event_box.connect("motion-notify-event", self._on_motion_notify)
|
||||
@@ -956,48 +957,6 @@ class NotificationRevealer(SlideRevealer):
|
||||
"""Smoother easing function for better animation quality"""
|
||||
return 1 - pow(1 - t, 3)
|
||||
|
||||
def _on_popup_enter(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
self._is_popup_hovered = True
|
||||
self._cancel_close_button_hide()
|
||||
self.close_button_revealer.set_reveal_child(True)
|
||||
return False
|
||||
|
||||
def _on_popup_leave(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
self._is_popup_hovered = False
|
||||
self._schedule_close_button_hide()
|
||||
return False
|
||||
|
||||
def _on_close_button_enter(self, widget, event):
|
||||
self._is_close_button_hovered = True
|
||||
self._cancel_close_button_hide()
|
||||
return False
|
||||
|
||||
def _on_close_button_leave(self, widget, event):
|
||||
self._is_close_button_hovered = False
|
||||
self._schedule_close_button_hide()
|
||||
return False
|
||||
|
||||
def _schedule_close_button_hide(self):
|
||||
self._cancel_close_button_hide()
|
||||
self._close_button_hide_timeout_id = GLib.timeout_add(
|
||||
80, self._do_hide_close_button
|
||||
)
|
||||
|
||||
def _cancel_close_button_hide(self):
|
||||
if self._close_button_hide_timeout_id is not None:
|
||||
GLib.source_remove(self._close_button_hide_timeout_id)
|
||||
self._close_button_hide_timeout_id = None
|
||||
|
||||
def _do_hide_close_button(self):
|
||||
self._close_button_hide_timeout_id = None
|
||||
if not self._is_popup_hovered and not self._is_close_button_hovered:
|
||||
self.close_button_revealer.set_reveal_child(False)
|
||||
return False
|
||||
|
||||
def _on_button_press(self, widget, event):
|
||||
if event.button != 1:
|
||||
return False
|
||||
|
||||
@@ -13,6 +13,7 @@ from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
import shared.data as data
|
||||
from services.modus import notification_service
|
||||
from shared.widgets.clipping_box import ClippingBox
|
||||
from shared.widgets.close_button_revealer import CloseButtonRevealerMixin
|
||||
from shared.widgets.custom_image import CustomImage
|
||||
from shared.window.applet_window import AppletWindow
|
||||
from utils.functions import clear_children, escape_markup_text
|
||||
@@ -33,7 +34,7 @@ from window.notification.unified_cache import (
|
||||
)
|
||||
|
||||
|
||||
class ExpandableNotificationGroup(Box):
|
||||
class ExpandableNotificationGroup(CloseButtonRevealerMixin, Box):
|
||||
def __init__(self, app_name, notifications, **kwargs):
|
||||
super().__init__(
|
||||
name="notification-group", orientation="v", spacing=0, **kwargs
|
||||
@@ -457,7 +458,7 @@ class ExpandableNotificationGroup(Box):
|
||||
return True
|
||||
|
||||
|
||||
class NotificationCenterWidget(NotificationWidget):
|
||||
class NotificationCenterWidget(CloseButtonRevealerMixin, NotificationWidget):
|
||||
def __init__(self, notification, **kwargs):
|
||||
self.notification_id = notification.cache_id
|
||||
self.cache_metadata = getattr(notification, "cache_metadata", {})
|
||||
@@ -539,7 +540,7 @@ class NotificationCenterWidget(NotificationWidget):
|
||||
setup_cursor_hover(self.close_button)
|
||||
|
||||
self._is_close_button_hovered = False
|
||||
self._is_content_hovered = False
|
||||
self._hovered = False
|
||||
self._close_button_hide_timeout_id = None
|
||||
|
||||
close_event_box = EventBox(
|
||||
@@ -549,7 +550,7 @@ class NotificationCenterWidget(NotificationWidget):
|
||||
close_event_box.connect("enter-notify-event", self._on_close_button_enter)
|
||||
close_event_box.connect("leave-notify-event", self._on_close_button_leave)
|
||||
|
||||
self.close_revealer = Gtk.Revealer(
|
||||
self.close_button_revealer = Gtk.Revealer(
|
||||
transition_type=Gtk.RevealerTransitionType.CROSSFADE,
|
||||
transition_duration=200,
|
||||
child=close_event_box,
|
||||
@@ -618,12 +619,12 @@ class NotificationCenterWidget(NotificationWidget):
|
||||
],
|
||||
child=content,
|
||||
)
|
||||
content_event_box.connect("enter-notify-event", self._on_content_enter)
|
||||
content_event_box.connect("leave-notify-event", self._on_content_leave)
|
||||
content_event_box.connect("enter-notify-event", self._on_hover_enter)
|
||||
content_event_box.connect("leave-notify-event", self._on_hover_leave)
|
||||
|
||||
overlay = Gtk.Overlay()
|
||||
overlay.add(content_event_box)
|
||||
overlay.add_overlay(self.close_revealer)
|
||||
overlay.add_overlay(self.close_button_revealer)
|
||||
overlay.show_all()
|
||||
return overlay
|
||||
|
||||
@@ -697,48 +698,6 @@ class NotificationCenterWidget(NotificationWidget):
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing notification {self.notification_id}: {e}")
|
||||
|
||||
def _on_content_enter(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
self._is_content_hovered = True
|
||||
self._cancel_close_button_hide()
|
||||
self.close_revealer.set_reveal_child(True)
|
||||
return False
|
||||
|
||||
def _on_content_leave(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
self._is_content_hovered = False
|
||||
self._schedule_close_button_hide()
|
||||
return False
|
||||
|
||||
def _on_close_button_enter(self, widget, event):
|
||||
self._is_close_button_hovered = True
|
||||
self._cancel_close_button_hide()
|
||||
return False
|
||||
|
||||
def _on_close_button_leave(self, widget, event):
|
||||
self._is_close_button_hovered = False
|
||||
self._schedule_close_button_hide()
|
||||
return False
|
||||
|
||||
def _schedule_close_button_hide(self):
|
||||
self._cancel_close_button_hide()
|
||||
self._close_button_hide_timeout_id = GLib.timeout_add(
|
||||
80, self._do_hide_close_button
|
||||
)
|
||||
|
||||
def _cancel_close_button_hide(self):
|
||||
if self._close_button_hide_timeout_id is not None:
|
||||
GLib.source_remove(self._close_button_hide_timeout_id)
|
||||
self._close_button_hide_timeout_id = None
|
||||
|
||||
def _do_hide_close_button(self):
|
||||
self._close_button_hide_timeout_id = None
|
||||
if not self._is_content_hovered and not self._is_close_button_hovered:
|
||||
self.close_revealer.set_reveal_child(False)
|
||||
return False
|
||||
|
||||
# Override to disable timeout functionality
|
||||
def start_timeout(self):
|
||||
pass
|
||||
|
||||
@@ -574,16 +574,7 @@ class ApplicationSwitcher(Window):
|
||||
item.preview_image.set_from_pixbuf(pixbuf)
|
||||
|
||||
if self.get_visible() and config().get("switcher_live_preview", True):
|
||||
fps_delay = config().get("switcher.live_preview_delay_ms", 200)
|
||||
invoke_repeater(
|
||||
fps_delay,
|
||||
lambda: (
|
||||
self._capture.capture_by_handle(full_addr, 300, 168, True)
|
||||
if self.get_visible()
|
||||
else False
|
||||
),
|
||||
initial_call=False,
|
||||
)
|
||||
self._capture.capture_by_handle(full_addr, 300, 168, True)
|
||||
except Exception:
|
||||
logger.exception("_on_frame_ready error")
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "fabric"
|
||||
version = "0.0.2"
|
||||
source = { git = "https://github.com/Fabric-Development/fabric.git#85cacf660b1a324525a4dd9b512521e55ef7f89b" }
|
||||
source = { git = "https://github.com/Fabric-Development/fabric.git?rev=85cacf660b1a324525a4dd9b512521e55ef7f89b#85cacf660b1a324525a4dd9b512521e55ef7f89b" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "loguru" },
|
||||
@@ -268,7 +268,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fabric", git = "https://github.com/Fabric-Development/fabric.git" },
|
||||
{ name = "fabric", git = "https://github.com/Fabric-Development/fabric.git?rev=85cacf660b1a324525a4dd9b512521e55ef7f89b" },
|
||||
{ name = "keyring", specifier = "==25.7.0" },
|
||||
{ name = "keyrings-alt", specifier = "==5.0.2" },
|
||||
{ name = "psutil", specifier = "==7.2.2" },
|
||||
|
||||
Reference in new issue
Block a user