updates
This commit is contained in:
27 files changed
+2725
-221
No files matched your search
@@ -2,8 +2,9 @@ import os
|
||||
import json
|
||||
import config.data as data
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.datetime import DateTime
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.system_tray.widgets import SystemTray
|
||||
from .battery import Battery
|
||||
from .metrics import Metrics
|
||||
@@ -11,6 +12,10 @@ from .controls import Controls
|
||||
from .workspaces import workspace
|
||||
from .indicators import Indicators
|
||||
from .applications import Applications
|
||||
from .music_player import MusicPlayer
|
||||
from fabric.hyprland.widgets import get_hyprland_connection, Language
|
||||
import utils.icons as icons
|
||||
from fabric.hyprland.service import HyprlandEvent
|
||||
|
||||
|
||||
class DockComponents(Box):
|
||||
@@ -26,11 +31,19 @@ class DockComponents(Box):
|
||||
orientation_val=orientation_val, dock_instance=dock_instance
|
||||
)
|
||||
|
||||
self.connection = get_hyprland_connection()
|
||||
self.lang_label = Label(name="lang-label")
|
||||
self.language = Button(
|
||||
name="language", h_align="center", v_align="center", child=self.lang_label
|
||||
)
|
||||
self.on_language_switch()
|
||||
self.connection.connect("event::activelayout", self.on_language_switch)
|
||||
|
||||
# Initialize component visibility from data
|
||||
self.component_visibility = data.DOCK_COMPONENTS_VISIBILITY
|
||||
|
||||
# Create components
|
||||
self.workspaces = workspace # Use the workspace instance directly
|
||||
self.workspaces = workspace
|
||||
self.metrics = Metrics()
|
||||
self.battery = Battery()
|
||||
self.date_time = DateTime(
|
||||
@@ -43,6 +56,7 @@ class DockComponents(Box):
|
||||
)
|
||||
self.controls = Controls()
|
||||
self.indicators = Indicators()
|
||||
self.music_player = MusicPlayer()
|
||||
self.systray = SystemTray(
|
||||
icon_size=18,
|
||||
spacing=4,
|
||||
@@ -58,7 +72,9 @@ class DockComponents(Box):
|
||||
self.applications,
|
||||
self.indicators,
|
||||
self.battery,
|
||||
self.music_player,
|
||||
self.date_time,
|
||||
self.language,
|
||||
self.systray,
|
||||
]
|
||||
|
||||
@@ -70,9 +86,8 @@ class DockComponents(Box):
|
||||
self.apply_component_props()
|
||||
|
||||
# Apply invert style for specific themes and positions
|
||||
should_invert = (
|
||||
data.DOCK_THEME in ["Dense", "Edge"] or
|
||||
(data.DOCK_THEME == "Pills" and data.DOCK_POSITION in ["Left", "Right"])
|
||||
should_invert = data.DOCK_THEME in ["Dense", "Edge"] or (
|
||||
data.DOCK_THEME == "Pills" and data.DOCK_POSITION in ["Left", "Right"]
|
||||
)
|
||||
|
||||
if should_invert:
|
||||
@@ -88,8 +103,10 @@ class DockComponents(Box):
|
||||
"date_time": self.date_time,
|
||||
"controls": self.controls,
|
||||
"indicators": self.indicators,
|
||||
"music_player": self.music_player,
|
||||
"systray": self.systray,
|
||||
"applications": self.applications,
|
||||
"language": self.language,
|
||||
}
|
||||
|
||||
for component_name, widget in components.items():
|
||||
@@ -104,8 +121,10 @@ class DockComponents(Box):
|
||||
"date_time": self.date_time,
|
||||
"controls": self.controls,
|
||||
"indicators": self.indicators,
|
||||
"music_player": self.music_player,
|
||||
"systray": self.systray,
|
||||
"applications": self.applications,
|
||||
"language": self.language,
|
||||
}
|
||||
|
||||
if component_name in components and component_name in self.component_visibility:
|
||||
@@ -136,3 +155,16 @@ class DockComponents(Box):
|
||||
return self.component_visibility[component_name]
|
||||
|
||||
return None
|
||||
|
||||
def on_language_switch(self, _=None, event: HyprlandEvent = None):
|
||||
lang_data = (
|
||||
event.data[1]
|
||||
if event and event.data and len(event.data) > 1
|
||||
else Language().get_label()
|
||||
)
|
||||
self.language.set_tooltip_text(lang_data)
|
||||
if not data.VERTICAL:
|
||||
self.lang_label.set_label(lang_data[:2].lower())
|
||||
else:
|
||||
self.lang_label.add_style_class("icon")
|
||||
self.lang_label.set_markup(icons.keyboard)
|
||||
@@ -100,15 +100,19 @@ class Applications(Box):
|
||||
GLib.timeout_add_seconds(1, self.check_config_change)
|
||||
|
||||
def _build_app_identifiers_map(self):
|
||||
app_map = {}
|
||||
identifiers = {}
|
||||
for app in self._all_apps:
|
||||
if app.window_class:
|
||||
app_map[app.window_class.lower()] = app
|
||||
if app.executable:
|
||||
app_map[app.executable.lower()] = app
|
||||
if app.name:
|
||||
app_map[app.name.lower()] = app
|
||||
return app_map
|
||||
identifiers[app.name.lower()] = app
|
||||
if app.display_name:
|
||||
identifiers[app.display_name.lower()] = app
|
||||
if app.window_class:
|
||||
identifiers[app.window_class.lower()] = app
|
||||
if app.executable:
|
||||
identifiers[app.executable.split("/")[-1].lower()] = app
|
||||
if app.command_line:
|
||||
identifiers[app.command_line.split()[0].split("/")[-1].lower()] = app
|
||||
return identifiers
|
||||
|
||||
def find_app_by_key(self, key):
|
||||
if not key:
|
||||
@@ -230,14 +234,20 @@ class Applications(Box):
|
||||
cmd_to_run = app_identifier["executable"]
|
||||
elif "name" in app_identifier and app_identifier["name"]:
|
||||
cmd_to_run = app_identifier["name"]
|
||||
else:
|
||||
elif isinstance(app_identifier, str):
|
||||
cmd_to_run = app_identifier
|
||||
if cmd_to_run:
|
||||
exec_shell_command_async(f"nohup {cmd_to_run} &")
|
||||
else:
|
||||
address = instances[0].get("address")
|
||||
if address:
|
||||
exec_shell_command(f"hyprctl dispatch focuswindow address:{address}")
|
||||
focused = self.get_focused()
|
||||
idx = next(
|
||||
(i for i, inst in enumerate(instances) if inst["address"] == focused),
|
||||
-1,
|
||||
)
|
||||
next_inst = instances[(idx + 1) % len(instances)]
|
||||
exec_shell_command(
|
||||
f"hyprctl dispatch focuswindow address:{next_inst['address']}"
|
||||
)
|
||||
|
||||
def update_pinned_apps_file(self):
|
||||
config_path = get_relative_path("../../../config/dock.json")
|
||||
@@ -263,8 +273,14 @@ class Applications(Box):
|
||||
self.app_identifiers = self._build_app_identifiers_map()
|
||||
|
||||
def _normalize_window_class(self, class_name):
|
||||
# Implementation from dock.py
|
||||
return class_name.lower()
|
||||
if not class_name:
|
||||
return ""
|
||||
normalized = class_name.lower()
|
||||
suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"]
|
||||
for suffix in suffixes:
|
||||
if normalized.endswith(suffix):
|
||||
normalized = normalized[: -len(suffix)]
|
||||
return normalized
|
||||
|
||||
def update_applications(self):
|
||||
# Clear existing children
|
||||
@@ -368,7 +384,7 @@ class Applications(Box):
|
||||
)
|
||||
)
|
||||
|
||||
children.extend(open_buttons)
|
||||
children += open_buttons
|
||||
|
||||
# Add all children to the dock
|
||||
for child in children:
|
||||
@@ -382,44 +398,51 @@ class Applications(Box):
|
||||
|
||||
return True
|
||||
|
||||
def update_pinned_apps(self, app_identifier, index=None):
|
||||
# Check if app is already pinned
|
||||
is_pinned = False
|
||||
for pinned_app in self.pinned:
|
||||
if (
|
||||
isinstance(app_identifier, dict)
|
||||
and isinstance(pinned_app, dict)
|
||||
and app_identifier.get("name") == pinned_app.get("name")
|
||||
):
|
||||
is_pinned = True
|
||||
break
|
||||
elif app_identifier == pinned_app:
|
||||
is_pinned = True
|
||||
break
|
||||
|
||||
# If not pinned, add to pinned apps
|
||||
if not is_pinned:
|
||||
if hasattr(app_identifier, "desktop_app") and app_identifier.desktop_app:
|
||||
app = app_identifier.desktop_app
|
||||
app_data_obj = {
|
||||
"name": app.name,
|
||||
"display_name": app.display_name,
|
||||
"window_class": app.window_class,
|
||||
"executable": app.executable,
|
||||
"command_line": app.command_line,
|
||||
}
|
||||
if index is not None:
|
||||
self.pinned.insert(index, app_data_obj)
|
||||
else:
|
||||
self.pinned.append(app_data_obj)
|
||||
else:
|
||||
if index is not None:
|
||||
self.pinned.insert(index, app_identifier)
|
||||
else:
|
||||
self.pinned.append(app_identifier)
|
||||
|
||||
self.config["pinned_apps"] = self.pinned
|
||||
self.update_pinned_apps_file()
|
||||
self.update_applications()
|
||||
|
||||
def on_button_press(self, widget, event):
|
||||
# Handle right-click to pin/unpin applications
|
||||
if event.button == 3: # Right mouse button
|
||||
app_identifier = widget.app_identifier
|
||||
instances = widget.instances
|
||||
|
||||
# Check if app is already pinned
|
||||
is_pinned = False
|
||||
for pinned_app in self.pinned:
|
||||
if (
|
||||
isinstance(app_identifier, dict)
|
||||
and isinstance(pinned_app, dict)
|
||||
and app_identifier.get("name") == pinned_app.get("name")
|
||||
):
|
||||
is_pinned = True
|
||||
break
|
||||
elif app_identifier == pinned_app:
|
||||
is_pinned = True
|
||||
break
|
||||
|
||||
# If not pinned, add to pinned apps
|
||||
if not is_pinned:
|
||||
if hasattr(widget, "desktop_app") and widget.desktop_app:
|
||||
app = widget.desktop_app
|
||||
app_data_obj = {
|
||||
"name": app.name,
|
||||
"display_name": app.display_name,
|
||||
"window_class": app.window_class,
|
||||
"executable": app.executable,
|
||||
"command_line": app.command_line,
|
||||
}
|
||||
self.pinned.append(app_data_obj)
|
||||
else:
|
||||
self.pinned.append(app_identifier)
|
||||
|
||||
self.config["pinned_apps"] = self.pinned
|
||||
self.update_pinned_apps_file()
|
||||
self.update_applications()
|
||||
self.update_pinned_apps(app_identifier)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -521,10 +544,14 @@ class Applications(Box):
|
||||
if not self._drag_in_progress:
|
||||
return
|
||||
|
||||
# Process drag end...
|
||||
|
||||
self._drag_in_progress = False
|
||||
|
||||
# Allow dock to hide again
|
||||
if self.dock_instance:
|
||||
self.dock_instance.prevent_hiding(False)
|
||||
|
||||
def get_focused(self):
|
||||
try:
|
||||
return json.loads(
|
||||
self.conn.send_command("j/activewindow").reply.decode()
|
||||
).get("address", "")
|
||||
except json.JSONDecodeError:
|
||||
return ""
|
||||
@@ -37,7 +37,6 @@ class Battery(Box):
|
||||
self.circle_button = Button(
|
||||
name="battery-button",
|
||||
child=self.circle,
|
||||
style="padding: 0; margin: 0; background: none;",
|
||||
)
|
||||
|
||||
# Add event handling for hover effects
|
||||
@@ -147,7 +146,6 @@ class Battery(Box):
|
||||
|
||||
percentage = self._battery.percentage
|
||||
state = self._battery.state
|
||||
charging = state in ["CHARGING", "FULLY_CHARGED"]
|
||||
# Get capacity as integer by converting from float
|
||||
capacity = int(float(self._battery.capacity.rstrip("%")))
|
||||
tooltip_points = []
|
||||
@@ -211,7 +209,7 @@ class Battery(Box):
|
||||
|
||||
# Update circle progress
|
||||
self.circle.set_value(percentage / 100.0)
|
||||
|
||||
|
||||
# Only show percentage label if not at 100%
|
||||
if percentage < 100:
|
||||
self.level.set_label(f"{int(percentage)}%")
|
||||
@@ -224,9 +222,17 @@ class Battery(Box):
|
||||
self.circle.remove_style_class("performance")
|
||||
self.circle.remove_style_class("balanced")
|
||||
|
||||
# Apply power profile styling
|
||||
# Apply power profile styling and icons
|
||||
if power_profile:
|
||||
self.circle.add_style_class(power_profile)
|
||||
if power_profile == "power-saver":
|
||||
self.icon.set_markup(icons.power_saving)
|
||||
elif power_profile == "performance":
|
||||
self.icon.set_markup(icons.power_performance)
|
||||
else: # balanced
|
||||
self.icon.set_markup(icons.battery)
|
||||
else:
|
||||
self.icon.set_markup(icons.battery)
|
||||
|
||||
# Apply alert styling if battery is low AND not charging
|
||||
if percentage <= 15 and not charging:
|
||||
@@ -236,17 +242,18 @@ class Battery(Box):
|
||||
self.icon.remove_style_class("alert")
|
||||
self.circle.remove_style_class("alert")
|
||||
|
||||
# Update icon based on battery state
|
||||
if state == "FULLY_CHARGED":
|
||||
self.icon.set_markup(icons.battery)
|
||||
elif state == "CHARGING":
|
||||
self.icon.set_markup(icons.charging)
|
||||
elif percentage <= 15 and state == "DISCHARGING":
|
||||
self.icon.set_markup(icons.alert)
|
||||
elif state == "DISCHARGING":
|
||||
self.icon.set_markup(icons.discharging)
|
||||
else:
|
||||
self.icon.set_markup(icons.battery)
|
||||
# Update icon based on battery state if not in power profile mode
|
||||
if not power_profile:
|
||||
if state == "FULLY_CHARGED":
|
||||
self.icon.set_markup(icons.battery)
|
||||
elif state == "CHARGING":
|
||||
self.icon.set_markup(icons.charging)
|
||||
elif percentage <= 15 and state == "DISCHARGING":
|
||||
self.icon.set_markup(icons.alert)
|
||||
elif state == "DISCHARGING":
|
||||
self.icon.set_markup(icons.discharging)
|
||||
else:
|
||||
self.icon.set_markup(icons.battery)
|
||||
|
||||
self.set_visible(True)
|
||||
return True
|
||||
@@ -3,8 +3,6 @@ from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from gi.repository import Gdk, GLib
|
||||
|
||||
|
||||
import config.data as data
|
||||
import utils.icons as icons
|
||||
@@ -15,9 +13,9 @@ from fabric.bluetooth import BluetoothClient
|
||||
class WifiIndicator(Button):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="button-bar-network", **kwargs)
|
||||
|
||||
|
||||
# Initialize NetworkClient
|
||||
self.network = NetworkClient()
|
||||
self.network = NetworkClient()
|
||||
# Create progress bar and wifi label
|
||||
self.progress_bar = CircularProgressBar(
|
||||
name="button-network",
|
||||
@@ -27,16 +25,13 @@ class WifiIndicator(Button):
|
||||
end_angle=390,
|
||||
)
|
||||
self.wifi_label = Label(name="network-label", markup=icons.loader)
|
||||
|
||||
|
||||
# Create overlay with progress bar and label
|
||||
self.add(Overlay(
|
||||
child=self.progress_bar,
|
||||
overlays=self.wifi_label
|
||||
))
|
||||
self.add(Overlay(child=self.progress_bar, overlays=self.wifi_label))
|
||||
|
||||
# Connect signals
|
||||
self.network.connect("ready", self.on_network_ready)
|
||||
|
||||
|
||||
# If already ready, set up signals immediately
|
||||
if self.network.is_ready:
|
||||
self.setup_signals()
|
||||
@@ -51,22 +46,25 @@ class WifiIndicator(Button):
|
||||
self.network.connect("wifi-device-removed", self.on_wifi_device_removed)
|
||||
self.network.connect("ethernet-device-added", self.on_ethernet_device_added)
|
||||
self.network.connect("ethernet-device-removed", self.on_ethernet_device_removed)
|
||||
|
||||
|
||||
# Connect to WiFi device if available
|
||||
if self.network.wifi_device:
|
||||
self.network.wifi_device.connect("changed", self.on_wifi_changed)
|
||||
self.network.wifi_device.connect("ap-added", self.on_wifi_changed)
|
||||
self.network.wifi_device.connect("ap-removed", self.on_wifi_changed)
|
||||
|
||||
|
||||
# Connect to Ethernet device if available
|
||||
if self.network.ethernet_device:
|
||||
self.network.ethernet_device.connect("changed", self.on_ethernet_changed)
|
||||
|
||||
|
||||
self.update_state()
|
||||
|
||||
def update_state(self):
|
||||
# Check ethernet first
|
||||
if self.network.ethernet_device and self.network.ethernet_device.state == "activated":
|
||||
if (
|
||||
self.network.ethernet_device
|
||||
and self.network.ethernet_device.state == "activated"
|
||||
):
|
||||
self.wifi_label.set_markup(icons.world)
|
||||
self.progress_bar.value = 1.0 # Full circle for ethernet
|
||||
tooltip = f"Connected to Ethernet ({self.network.ethernet_device.speed})"
|
||||
@@ -133,19 +131,19 @@ class WifiIndicator(Button):
|
||||
class BluetoothIndicator(Button):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="button-bar-bluetooth", **kwargs)
|
||||
|
||||
|
||||
# Initialize BluetoothClient
|
||||
self.bluetooth = BluetoothClient()
|
||||
|
||||
|
||||
# Create bluetooth label
|
||||
self.bt_label = Label(name="bluetooth-label", markup=icons.loader)
|
||||
self.add(self.bt_label)
|
||||
|
||||
|
||||
# Connect signals
|
||||
self.bluetooth.connect("changed", self.on_bluetooth_changed)
|
||||
self.bluetooth.connect("device-added", self.on_device_added)
|
||||
self.bluetooth.connect("device-removed", self.on_device_removed)
|
||||
|
||||
|
||||
# Initial state update
|
||||
self.update_state()
|
||||
|
||||
@@ -193,4 +191,4 @@ class Indicators(Box):
|
||||
children=[WifiIndicator(), BluetoothIndicator()],
|
||||
**kwargs,
|
||||
)
|
||||
self.show_all()
|
||||
self.show_all()
|
||||
@@ -0,0 +1,622 @@
|
||||
import config.data as data
|
||||
import utils.icons as icons
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.stack import Stack
|
||||
from gi.repository import GLib, Gdk, GdkPixbuf
|
||||
from services.mpris import MediaManager
|
||||
from utils.circle_image import CircleImage
|
||||
from utils.audio_visualizer import SpectrumRender
|
||||
import urllib.parse
|
||||
import os
|
||||
import requests
|
||||
import tempfile
|
||||
|
||||
|
||||
class MusicPlayer(EventBox):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="music-player",
|
||||
events=["button-press"],
|
||||
**kwargs
|
||||
)
|
||||
|
||||
orientation = "v" if data.VERTICAL else "h"
|
||||
|
||||
# Create main container box
|
||||
self.main_container = Box(
|
||||
name="music-player-container",
|
||||
spacing=4,
|
||||
orientation=orientation,
|
||||
)
|
||||
|
||||
# Initialize media manager
|
||||
self._media_manager = MediaManager()
|
||||
self._media_manager.connect("player-appeared", self._on_player_changed)
|
||||
self._media_manager.connect("player-vanished", self._on_player_changed)
|
||||
|
||||
self._current_player = None
|
||||
self._manually_selected_player = None
|
||||
self._update_timeout_id = None
|
||||
self._current_player_signals = [] # Track signal connections for current player
|
||||
|
||||
# Marquee animation variables
|
||||
self._marquee_timeout_id = None
|
||||
self._marquee_position = 0
|
||||
self._original_title = ""
|
||||
self._title_needs_marquee = False
|
||||
|
||||
# Thumbnail spinning animation variables
|
||||
self._spin_timeout_id = None
|
||||
self._spin_angle = 0
|
||||
|
||||
# Visualizer state
|
||||
self._show_visualizer = False
|
||||
|
||||
# Create music player view with stack for visualizer/text switching
|
||||
self._create_music_player_view()
|
||||
|
||||
# Set the main container as child
|
||||
self.add(self.main_container)
|
||||
|
||||
# Connect click handler for the entire music player
|
||||
self.connect("button-press-event", self._on_music_player_clicked)
|
||||
|
||||
# Hide initially - will show when media players become available
|
||||
self.set_visible(False)
|
||||
|
||||
# Delay initial update to allow MediaManager to initialize properly
|
||||
GLib.timeout_add(100, self._delayed_initial_update)
|
||||
|
||||
def _create_music_player_view(self):
|
||||
"""Create the music player view with stack for visualizer/text switching like PlayerSmall"""
|
||||
orientation = "v" if data.VERTICAL else "h"
|
||||
|
||||
# Create CircleImage for album art thumbnails (clickable to toggle visualizer)
|
||||
self.album_thumbnail = CircleImage(
|
||||
name="music-album-thumbnail",
|
||||
size=34, # Appropriate size for dock
|
||||
v_align="center",
|
||||
visible=True, # Always visible, will show default or album art
|
||||
)
|
||||
|
||||
# Make album thumbnail clickable to toggle visualizer
|
||||
self.album_thumbnail.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
self.album_thumbnail.connect("button-press-event", self._on_thumbnail_clicked)
|
||||
|
||||
# Create track info display
|
||||
self.track_label = Label(
|
||||
name="music-track",
|
||||
label="No Media",
|
||||
ellipsize=0, # No ellipsize for marquee
|
||||
h_align="center",
|
||||
)
|
||||
# Fixed width to prevent box size changes
|
||||
self.track_label.set_size_request(100, -1)
|
||||
self.track_label.set_hexpand(False)
|
||||
self.track_label.set_vexpand(False)
|
||||
|
||||
# Create spectrum visualizer lazily to save memory
|
||||
self.cavalcade = None
|
||||
self.cavalcade_box = None
|
||||
|
||||
# Create stack for crossfade between visualizer and text
|
||||
self.center_stack = Stack(
|
||||
name="music-player-stack",
|
||||
transition_type="crossfade",
|
||||
transition_duration=100,
|
||||
v_align="center",
|
||||
v_expand=False,
|
||||
children=[self.track_label] # Start with just track label
|
||||
)
|
||||
# Start with track label visible
|
||||
self.center_stack.set_visible_child(self.track_label)
|
||||
|
||||
# Create control buttons
|
||||
self._create_controls()
|
||||
|
||||
# Create main music player layout using CenterBox like PlayerSmall
|
||||
self.music_player_box = CenterBox(
|
||||
name="music-player-centerbox",
|
||||
orientation=orientation,
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
v_align="center",
|
||||
v_expand=False,
|
||||
start_children=self.album_thumbnail,
|
||||
center_children=self.center_stack,
|
||||
end_children=self.controls_box,
|
||||
)
|
||||
|
||||
# Add to main container
|
||||
self.main_container.add(self.music_player_box)
|
||||
|
||||
def _load_album_art(self, art_url):
|
||||
"""Load album art from URL and set it to the CircleImage"""
|
||||
# Cache the last loaded URL to avoid reloading the same image
|
||||
if hasattr(self, '_last_art_url') and self._last_art_url == art_url:
|
||||
return
|
||||
|
||||
if not art_url:
|
||||
self._hide_album_art()
|
||||
self._last_art_url = None
|
||||
return
|
||||
|
||||
try:
|
||||
if art_url.startswith('file://'):
|
||||
# Handle local file URLs
|
||||
file_path = urllib.parse.unquote(art_url[7:]) # Remove 'file://' prefix
|
||||
if os.path.exists(file_path):
|
||||
self.album_thumbnail.set_image_from_file(file_path)
|
||||
self._show_album_art()
|
||||
self._last_art_url = art_url
|
||||
else:
|
||||
self._hide_album_art()
|
||||
self._last_art_url = None
|
||||
elif art_url.startswith(('http://', 'https://')):
|
||||
# Handle remote URLs by downloading them first
|
||||
response = requests.get(art_url)
|
||||
if response.status_code == 200:
|
||||
# Create a temporary file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
|
||||
temp_file.write(response.content)
|
||||
temp_path = temp_file.name
|
||||
|
||||
# Load the image from the temporary file
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file(temp_path)
|
||||
self.album_thumbnail.set_image_from_pixbuf(pixbuf)
|
||||
self._show_album_art()
|
||||
self._last_art_url = art_url
|
||||
|
||||
# Clean up the temporary file
|
||||
os.unlink(temp_path)
|
||||
else:
|
||||
self._hide_album_art()
|
||||
self._last_art_url = None
|
||||
else:
|
||||
self._hide_album_art()
|
||||
self._last_art_url = None
|
||||
except Exception:
|
||||
self._hide_album_art()
|
||||
self._last_art_url = None
|
||||
|
||||
def _show_album_art(self):
|
||||
"""Show album art thumbnail"""
|
||||
self.album_thumbnail.set_visible(True)
|
||||
# Start spinning animation when showing album art
|
||||
self._start_spin_animation()
|
||||
|
||||
def _hide_album_art(self):
|
||||
"""Hide album art thumbnail"""
|
||||
self.album_thumbnail.set_visible(False)
|
||||
# Stop spinning animation when hiding album art
|
||||
self._stop_spin_animation()
|
||||
|
||||
def _start_spin_animation(self):
|
||||
"""Start the thumbnail spinning animation"""
|
||||
if not self._spin_timeout_id:
|
||||
self._spin_timeout_id = GLib.timeout_add(100, self._animate_spin) # 100ms to save CPU
|
||||
|
||||
def _stop_spin_animation(self):
|
||||
"""Stop the thumbnail spinning animation"""
|
||||
if self._spin_timeout_id:
|
||||
GLib.source_remove(self._spin_timeout_id)
|
||||
self._spin_timeout_id = None
|
||||
# Reset angle to 0
|
||||
self._spin_angle = 0
|
||||
self.album_thumbnail.angle = 0
|
||||
|
||||
def _animate_spin(self):
|
||||
"""Animate the thumbnail spinning"""
|
||||
if not self._current_player or not self.album_thumbnail.get_visible():
|
||||
return False
|
||||
|
||||
# Only spin when music is playing
|
||||
if self._current_player.status == "playing":
|
||||
self._spin_angle = (self._spin_angle + 5) % 360 # Rotate 2 degrees per frame
|
||||
self.album_thumbnail.angle = self._spin_angle
|
||||
|
||||
return True # Continue animation
|
||||
|
||||
def _update_player_indicators(self):
|
||||
"""Update the player indicators to show all available players"""
|
||||
# Get all available players
|
||||
available_players = self._media_manager.players
|
||||
|
||||
# Only show indicators if there are multiple players to save memory
|
||||
if len(available_players) <= 1:
|
||||
# Clear indicators if only one or no players
|
||||
for child in self.player_indicators_box.get_children():
|
||||
self.player_indicators_box.remove(child)
|
||||
return
|
||||
|
||||
# Clear existing indicators
|
||||
for child in self.player_indicators_box.get_children():
|
||||
self.player_indicators_box.remove(child)
|
||||
|
||||
current_player_name = self._current_player.player_name if self._current_player else "None"
|
||||
|
||||
# Create indicators for each player (only when multiple players exist)
|
||||
for player in available_players:
|
||||
player_name = player.player_name or "Unknown"
|
||||
player_icon_markup = self._get_player_icon(player_name)
|
||||
|
||||
# Create clickable button with indicator label
|
||||
indicator_button = Button(
|
||||
name="music-player-indicator-button",
|
||||
child=Label(
|
||||
name="music-player-indicator",
|
||||
markup=player_icon_markup,
|
||||
),
|
||||
can_focus=False,
|
||||
)
|
||||
|
||||
# Set tooltip with player info
|
||||
is_current_player = (player.player_name == current_player_name)
|
||||
tooltip_text = f"Player: {player_name}"
|
||||
if is_current_player:
|
||||
tooltip_text += " (Active)"
|
||||
else:
|
||||
tooltip_text += " - Click to switch"
|
||||
indicator_button.set_tooltip_text(tooltip_text)
|
||||
|
||||
# Add visual distinction for active player (you can style this with CSS)
|
||||
if is_current_player:
|
||||
indicator_button.get_child().set_name("music-player-indicator-active")
|
||||
else:
|
||||
indicator_button.get_child().set_name("music-player-indicator")
|
||||
|
||||
# Connect click handler with player reference
|
||||
indicator_button.connect("clicked", self._on_player_indicator_clicked, player)
|
||||
|
||||
self.player_indicators_box.add(indicator_button)
|
||||
|
||||
# Force refresh the indicators container
|
||||
self.player_indicators_box.show_all()
|
||||
|
||||
def _create_controls(self):
|
||||
"""Create the media control buttons"""
|
||||
self.controls_box = Box(
|
||||
name="music-controls",
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
spacing=2,
|
||||
)
|
||||
|
||||
# Previous button
|
||||
self.prev_button = Button(
|
||||
name="music-prev",
|
||||
child=Label(markup=icons.prev),
|
||||
can_focus=False,
|
||||
)
|
||||
self.prev_button.connect("clicked", self._on_previous_clicked)
|
||||
self.prev_button.set_tooltip_text("Previous Track")
|
||||
|
||||
# Play/Pause button
|
||||
self.play_pause_button = Button(
|
||||
name="music-play-pause",
|
||||
child=Label(name="play-pause-icon", markup=icons.play),
|
||||
can_focus=False,
|
||||
)
|
||||
self.play_pause_button.connect("clicked", self._on_play_pause_clicked)
|
||||
self.play_pause_button.set_tooltip_text("Play/Pause")
|
||||
|
||||
# Next button
|
||||
self.next_button = Button(
|
||||
name="music-next",
|
||||
child=Label(markup=icons.next),
|
||||
can_focus=False,
|
||||
)
|
||||
self.next_button.connect("clicked", self._on_next_clicked)
|
||||
self.next_button.set_tooltip_text("Next Track")
|
||||
|
||||
# Player indicators container
|
||||
self.player_indicators_box = Box(
|
||||
name="music-player-indicators",
|
||||
orientation="h" if not data.VERTICAL else "v",
|
||||
spacing=2,
|
||||
)
|
||||
|
||||
# Add buttons and indicators to controls
|
||||
self.controls_box.add(self.prev_button)
|
||||
self.controls_box.add(self.play_pause_button)
|
||||
self.controls_box.add(self.next_button)
|
||||
self.controls_box.add(self.player_indicators_box)
|
||||
|
||||
def _delayed_initial_update(self):
|
||||
"""Delayed initial update to allow MediaManager to initialize"""
|
||||
self._update_display()
|
||||
return False # Don't repeat this timeout
|
||||
|
||||
def _connect_player_signals(self, player):
|
||||
"""Connect to player signals for immediate updates"""
|
||||
if player:
|
||||
# Connect to metadata changes for immediate track/thumbnail updates
|
||||
metadata_signal = player.connect("metadata_changed", self._on_metadata_changed)
|
||||
status_signal = player.connect("playback_status_changed", self._on_playback_status_changed)
|
||||
self._current_player_signals = [metadata_signal, status_signal]
|
||||
|
||||
def _disconnect_player_signals(self):
|
||||
"""Disconnect from current player signals"""
|
||||
if self._current_player and self._current_player_signals:
|
||||
for signal_id in self._current_player_signals:
|
||||
try:
|
||||
self._current_player.disconnect(signal_id)
|
||||
except:
|
||||
pass # Signal might already be disconnected
|
||||
self._current_player_signals = []
|
||||
|
||||
def _on_metadata_changed(self, *_args):
|
||||
"""Handle immediate metadata changes (track, artist, album art)"""
|
||||
GLib.idle_add(self._update_display)
|
||||
|
||||
def _on_playback_status_changed(self, *_args):
|
||||
"""Handle immediate playback status changes"""
|
||||
GLib.idle_add(self._update_display)
|
||||
|
||||
def _on_player_changed(self, *_args):
|
||||
"""Handle player appearance/disappearance"""
|
||||
GLib.idle_add(self._update_display)
|
||||
|
||||
def _update_display(self):
|
||||
"""Update the display based on current player state"""
|
||||
|
||||
# Store previous player to detect changes
|
||||
previous_player = self._current_player
|
||||
|
||||
# Determine current player (manual selection takes priority)
|
||||
if self._manually_selected_player:
|
||||
# Check if manually selected player is still available
|
||||
manually_selected_available = next(
|
||||
(p for p in self._media_manager.players
|
||||
if p.player_name == self._manually_selected_player.player_name),
|
||||
None
|
||||
)
|
||||
if manually_selected_available:
|
||||
self._current_player = manually_selected_available
|
||||
self._manually_selected_player = manually_selected_available
|
||||
else:
|
||||
# Manual player no longer available, clear selection
|
||||
self._manually_selected_player = None
|
||||
self._current_player = self._media_manager.current_player
|
||||
else:
|
||||
self._current_player = self._media_manager.current_player
|
||||
|
||||
# Connect to new player signals if player changed
|
||||
if self._current_player != previous_player:
|
||||
self._disconnect_player_signals()
|
||||
if self._current_player:
|
||||
self._connect_player_signals(self._current_player)
|
||||
|
||||
|
||||
if not self._current_player:
|
||||
# No players available, hide the component
|
||||
self.set_visible(False)
|
||||
if self._update_timeout_id:
|
||||
GLib.source_remove(self._update_timeout_id)
|
||||
self._update_timeout_id = None
|
||||
if self._marquee_timeout_id:
|
||||
GLib.source_remove(self._marquee_timeout_id)
|
||||
self._marquee_timeout_id = None
|
||||
# Stop spinning animation when no player
|
||||
self._stop_spin_animation()
|
||||
return False
|
||||
|
||||
self.set_visible(True)
|
||||
|
||||
# Try to load album art, hide if not available
|
||||
album_art_url = self._current_player.album_image_url
|
||||
if album_art_url:
|
||||
self._load_album_art(album_art_url)
|
||||
else:
|
||||
# No album art available, hide thumbnails
|
||||
self._hide_album_art()
|
||||
|
||||
# Update stack display (text vs visualizer)
|
||||
self._update_stack_display()
|
||||
|
||||
# Update player indicators to show all available players
|
||||
self._update_player_indicators()
|
||||
|
||||
# Update play/pause button
|
||||
play_pause_icon = self.play_pause_button.get_child()
|
||||
if self._current_player.status == "playing":
|
||||
play_pause_icon.set_markup(icons.pause)
|
||||
else:
|
||||
play_pause_icon.set_markup(icons.play)
|
||||
|
||||
# Update track info with marquee animation
|
||||
track_title = self._current_player.track_title or "Unknown Track"
|
||||
max_display_chars = 12
|
||||
|
||||
self._original_title = track_title
|
||||
self._title_needs_marquee = len(track_title) > max_display_chars
|
||||
|
||||
# Reset marquee if title changed
|
||||
if self._original_title != getattr(self, '_last_original_title', ''):
|
||||
self._last_original_title = self._original_title
|
||||
self._marquee_position = 0
|
||||
self._start_marquee_animation()
|
||||
|
||||
self._update_marquee_text()
|
||||
self._update_tooltips()
|
||||
|
||||
# Update button sensitivity
|
||||
self.prev_button.set_sensitive(self._current_player.can_go_previous)
|
||||
self.next_button.set_sensitive(self._current_player.can_go_next)
|
||||
self.play_pause_button.set_sensitive(self._current_player.can_pause)
|
||||
|
||||
# Remove all polling - rely entirely on signals for updates
|
||||
if self._update_timeout_id:
|
||||
GLib.source_remove(self._update_timeout_id)
|
||||
self._update_timeout_id = None
|
||||
|
||||
return True
|
||||
|
||||
def _start_marquee_animation(self):
|
||||
"""Start or restart the marquee animation"""
|
||||
# Stop existing marquee animation
|
||||
if self._marquee_timeout_id:
|
||||
GLib.source_remove(self._marquee_timeout_id)
|
||||
self._marquee_timeout_id = None
|
||||
|
||||
if self._title_needs_marquee:
|
||||
self._marquee_timeout_id = GLib.timeout_add(250, self._animate_marquee)
|
||||
|
||||
def _animate_marquee(self):
|
||||
"""Animate the marquee scrolling"""
|
||||
if not self._current_player:
|
||||
return False
|
||||
|
||||
self._marquee_position += 1
|
||||
title_length = len(self._original_title)
|
||||
loop_length = title_length + 3 # Add spacing between loops
|
||||
|
||||
if self._marquee_position >= loop_length:
|
||||
self._marquee_position = 0
|
||||
|
||||
self._update_marquee_text()
|
||||
return True
|
||||
|
||||
def _update_marquee_text(self):
|
||||
"""Update the displayed text based on marquee position"""
|
||||
if not self._current_player:
|
||||
return
|
||||
|
||||
max_display_chars = 12
|
||||
|
||||
if self._title_needs_marquee:
|
||||
extended_text = self._original_title + " "
|
||||
start_pos = self._marquee_position
|
||||
end_pos = start_pos + max_display_chars
|
||||
|
||||
if end_pos <= len(extended_text):
|
||||
display_title = extended_text[start_pos:end_pos]
|
||||
else:
|
||||
part1 = extended_text[start_pos:]
|
||||
part2 = self._original_title[:end_pos - len(extended_text)]
|
||||
display_title = (part1 + part2)[:max_display_chars]
|
||||
else:
|
||||
display_title = self._original_title[:max_display_chars]
|
||||
|
||||
self.track_label.set_label(display_title)
|
||||
|
||||
def _get_player_icon(self, player_name):
|
||||
"""Get the appropriate icon for a player based on its name"""
|
||||
if not player_name:
|
||||
return icons.disc
|
||||
|
||||
player_name_lower = player_name.lower()
|
||||
|
||||
if "firefox" in player_name_lower:
|
||||
return icons.firefox
|
||||
elif "spotify" in player_name_lower:
|
||||
return icons.spotify
|
||||
elif any(browser in player_name_lower for browser in [
|
||||
"chromium", "chrome", "edge", "brave", "opera", "vivaldi"
|
||||
]):
|
||||
return icons.chromium
|
||||
else:
|
||||
return icons.disc
|
||||
|
||||
def _update_tooltips(self):
|
||||
"""Update tooltips with full track information"""
|
||||
if not self._current_player:
|
||||
return
|
||||
|
||||
# Get full track information for tooltip
|
||||
full_track_title = self._current_player.track_title or "Unknown Track"
|
||||
track_artist = self._current_player.track_artist or "Unknown Artist"
|
||||
player_name = self._current_player.player_name or "Media Player"
|
||||
|
||||
# Create tooltip text with full information
|
||||
tooltip_text = f"{full_track_title}\nby {track_artist}\n\nPlayer: {player_name}"
|
||||
self.track_label.set_tooltip_text(tooltip_text)
|
||||
|
||||
def _on_play_pause_clicked(self, _button):
|
||||
"""Handle play/pause button click"""
|
||||
if self._current_player:
|
||||
self._current_player.play_pause()
|
||||
# No need for manual update - signal will trigger it
|
||||
|
||||
def _on_previous_clicked(self, _button):
|
||||
"""Handle previous button click"""
|
||||
if self._current_player:
|
||||
self._current_player.previous()
|
||||
# No need for manual update - signal will trigger it
|
||||
|
||||
def _on_next_clicked(self, _button):
|
||||
"""Handle next button click"""
|
||||
if self._current_player:
|
||||
self._current_player.next()
|
||||
# No need for manual update - signal will trigger it
|
||||
|
||||
def _on_player_indicator_clicked(self, _button, player):
|
||||
"""Handle player indicator click to switch players"""
|
||||
if player:
|
||||
self._manually_selected_player = player
|
||||
self._update_display()
|
||||
|
||||
def _on_thumbnail_clicked(self, _widget, _event):
|
||||
"""Handle thumbnail click to toggle visualizer"""
|
||||
if self._current_player:
|
||||
self._show_visualizer = not self._show_visualizer
|
||||
self._update_stack_display()
|
||||
return True # Stop event propagation
|
||||
|
||||
def _on_music_player_clicked(self, _widget, _event):
|
||||
"""Handle music player click to toggle between text and visualizer view"""
|
||||
# Only allow toggling if we have a current player and the click is not on the album thumbnail or player indicators
|
||||
if self._current_player and _event.get_source() not in [self.album_thumbnail, self.player_indicators_box]:
|
||||
self._show_visualizer = not self._show_visualizer
|
||||
self._update_stack_display()
|
||||
return True
|
||||
|
||||
def _create_visualizer_if_needed(self):
|
||||
"""Create visualizer lazily to save memory"""
|
||||
if not self.cavalcade:
|
||||
self.cavalcade = SpectrumRender()
|
||||
self.cavalcade_box = self.cavalcade.get_spectrum_box()
|
||||
self.center_stack.add(self.cavalcade_box)
|
||||
|
||||
def _destroy_visualizer(self):
|
||||
"""Destroy visualizer to free memory when not needed"""
|
||||
if self.cavalcade_box:
|
||||
self.center_stack.remove(self.cavalcade_box)
|
||||
self.cavalcade_box = None
|
||||
if self.cavalcade and hasattr(self.cavalcade, 'cava'):
|
||||
self.cavalcade.cava.close()
|
||||
self.cavalcade = None
|
||||
|
||||
def _update_stack_display(self):
|
||||
"""Update stack to show either text or visualizer"""
|
||||
if self._show_visualizer and self._current_player:
|
||||
# Create visualizer only when needed
|
||||
self._create_visualizer_if_needed()
|
||||
if self.cavalcade_box:
|
||||
self.cavalcade_box.set_visible(True)
|
||||
self.cavalcade_box.show_all()
|
||||
self.center_stack.set_visible_child(self.cavalcade_box)
|
||||
else:
|
||||
# Show text label in stack
|
||||
self.center_stack.set_visible_child(self.track_label)
|
||||
# Destroy visualizer to save memory when not in use
|
||||
self._destroy_visualizer()
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup when component is destroyed"""
|
||||
# Disconnect player signals
|
||||
self._disconnect_player_signals()
|
||||
|
||||
# Clean up all timeouts
|
||||
if self._update_timeout_id:
|
||||
GLib.source_remove(self._update_timeout_id)
|
||||
if self._marquee_timeout_id:
|
||||
GLib.source_remove(self._marquee_timeout_id)
|
||||
if self._spin_timeout_id:
|
||||
GLib.source_remove(self._spin_timeout_id)
|
||||
|
||||
# Clean up visualizer
|
||||
self._destroy_visualizer()
|
||||
Reference in new issue
Block a user