From 257581455405a961ece44b8ddb93c93ee3d556c2 Mon Sep 17 00:00:00 2001 From: tr1x_em Date: Wed, 6 Aug 2025 20:58:18 +0530 Subject: [PATCH] Per App Volume Control Enhanced --- modules/controlcenter/expanded_player.py | 10 +- modules/controlcenter/main.py | 124 +++++++----- modules/controlcenter/per_app_volume.py | 230 ++++++++++++----------- styles/controlcenter.css | 44 ++--- 4 files changed, 226 insertions(+), 182 deletions(-) diff --git a/modules/controlcenter/expanded_player.py b/modules/controlcenter/expanded_player.py index c4b0926..f000a3f 100644 --- a/modules/controlcenter/expanded_player.py +++ b/modules/controlcenter/expanded_player.py @@ -74,7 +74,7 @@ class EmbeddedExpandedPlayer(Box): self.control_center = control_center self.mpris_manager = get_shared_mpris_manager() - # Create back button + # Create back button (hidden in header) self.back_button = Button( name="back-button", child=Label(label="← Back"), @@ -84,11 +84,19 @@ class EmbeddedExpandedPlayer(Box): # Create expanded player content self.player_content = PlayerBoxStack(self.mpris_manager) + # Add escape key binding for navigation back + try: + if hasattr(self.control_center, "add_keybinding"): + self.control_center.add_keybinding("Escape", self._on_back_clicked) + except Exception: + pass # Ignore if keybinding fails + self.children = [ Box( orientation="horizontal", h_expand=True, style_classes="menu", + visible=False, # Hide header to remove title and back button children=[ self.back_button, Box(h_expand=True), # Spacer diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py index 9bd5bf2..f37f26e 100644 --- a/modules/controlcenter/main.py +++ b/modules/controlcenter/main.py @@ -20,12 +20,15 @@ from modules.controlcenter.player import PlayerBoxStack from modules.controlcenter.per_app_volume import PerAppVolumeControl from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer from services.mpris import MprisPlayerManager +from fabric.widgets.svg import Svg +from fabric.utils.helpers import get_relative_path brightness_service = Brightness.get_initial() # Shared MPRIS manager to reduce memory usage _shared_mpris_manager = None + def get_shared_mpris_manager(): """Get shared MPRIS manager instance to reduce memory usage.""" global _shared_mpris_manager @@ -33,9 +36,6 @@ def get_shared_mpris_manager(): _shared_mpris_manager = MprisPlayerManager() return _shared_mpris_manager -# FIX: Icon not showing up in control center -# TODO: Add Player - class ModusControlCenter(Window): def __init__(self, **kwargs): @@ -53,12 +53,12 @@ class ModusControlCenter(Window): self.focus_mode = modus_service.dont_disturb self._updating_brightness = False self._updating_volume = False - + # Lazy loading flags self._music_initialized = False self._per_app_volume_initialized = False self._expanded_player_initialized = False - + # Store references for cleanup self._signal_connections = [] self._music_widget_content = None @@ -73,11 +73,13 @@ class ModusControlCenter(Window): music = modus_service.sc("music-changed", self.audio_changed) # Store signal connections for cleanup - self._signal_connections.extend([ - audio_service.connect("changed", self.audio_changed), - audio_service.connect("changed", self.volume_changed), - modus_service.connect("dont-disturb-changed", self.dnd_changed) - ]) + self._signal_connections.extend( + [ + audio_service.connect("changed", self.audio_changed), + audio_service.connect("changed", self.volume_changed), + modus_service.connect("dont-disturb-changed", self.dnd_changed), + ] + ) self.wlan_label = Label(wlan, name="wifi-widget-label", h_align="start") if bluetooth != "disabled": @@ -134,9 +136,7 @@ class ModusControlCenter(Window): # Create placeholder music widget - lazy load content when needed self.music_widget = Box( - name="music-widget", - h_align="start", - children=[] # Empty initially + name="music-widget", h_align="start", children=[] # Empty initially ) self.has_bluetooth_open = False @@ -288,17 +288,23 @@ class ModusControlCenter(Window): Box( orientation="horizontal", spacing=8, + v_expand=False, children=[ self.volume_scale, Button( name="per-app-volume-button", - child=Label(label="⚙", name="per-app-volume-icon"), + child=Svg( + svg_file=get_relative_path( + "../../config/assets/icons/player/audio-switcher.svg" + ), + name="per-app-volume-icon", + ), on_clicked=self.open_per_app_volume, ), - ] + ], ), Label( - label=" ", name="brightness-widget-icon", h_align="start" + label=" ", name="brightness-widget-icon", h_align="start" ), ], ), @@ -333,10 +339,10 @@ class ModusControlCenter(Window): self.widgets.set_size_request(300, -1) self.children = self.center_box - + # Connect to visibility changes for cleanup self.connect("notify::visible", self._on_visibility_changed) - + # Periodic cleanup timer (every 5 minutes when visible) GLib.timeout_add_seconds(300, self._periodic_cleanup) @@ -345,33 +351,33 @@ class ModusControlCenter(Window): # Disconnect all signal connections for connection in self._signal_connections: try: - if hasattr(connection, 'disconnect'): + if hasattr(connection, "disconnect"): connection.disconnect() - elif hasattr(connection, 'handler_disconnect'): + elif hasattr(connection, "handler_disconnect"): # For GObject connections connection.handler_disconnect() except Exception: pass self._signal_connections.clear() - + # Clean up all widgets self._cleanup_when_hidden() - + # Clean up managers - if hasattr(self, 'wifi_man') and self.wifi_man: + if hasattr(self, "wifi_man") and self.wifi_man: try: - if hasattr(self.wifi_man, 'destroy'): + if hasattr(self.wifi_man, "destroy"): self.wifi_man.destroy() except Exception: pass - - if hasattr(self, 'bluetooth_man') and self.bluetooth_man: + + if hasattr(self, "bluetooth_man") and self.bluetooth_man: try: - if hasattr(self.bluetooth_man, 'destroy'): + if hasattr(self.bluetooth_man, "destroy"): self.bluetooth_man.destroy() except Exception: pass - + super().destroy() def _on_visibility_changed(self, widget, param): @@ -382,7 +388,9 @@ class ModusControlCenter(Window): def _cleanup_when_hidden(self): """Clean up resources when widget is hidden to reduce memory usage""" # Clean up music widget content if it exists - if self._music_widget_content and hasattr(self._music_widget_content, 'destroy'): + if self._music_widget_content and hasattr( + self._music_widget_content, "destroy" + ): try: # Remove from parent container first current_children = list(self.music_widget.children) @@ -395,57 +403,65 @@ class ModusControlCenter(Window): self._music_initialized = False except Exception as e: logger.warning(f"Failed to cleanup music widget content: {e}") - + # Clean up per-app volume widget - if self._per_app_volume_widget and hasattr(self._per_app_volume_widget, 'destroy'): + if self._per_app_volume_widget and hasattr( + self._per_app_volume_widget, "destroy" + ): try: self._per_app_volume_widget.destroy() self._per_app_volume_widget = None self._per_app_volume_initialized = False except Exception: pass - - # Clean up expanded player widget - if self._expanded_player_widget and hasattr(self._expanded_player_widget, 'destroy'): + + # Clean up expanded player widget + if self._expanded_player_widget and hasattr( + self._expanded_player_widget, "destroy" + ): try: self._expanded_player_widget.destroy() self._expanded_player_widget = None self._expanded_player_initialized = False except Exception: pass - + # Reset widget containers to free memory self.bluetooth_widgets = None - self.wifi_widgets = None + self.wifi_widgets = None self.per_app_volume_widgets = None self.expanded_player_widgets = None - + # Reset center boxes self.bluetooth_center_box = None self.wifi_center_box = None self.per_app_volume_center_box = None self.expanded_player_center_box = None - + # Force garbage collection import gc + gc.collect() - + def _periodic_cleanup(self): """Periodic cleanup to keep memory usage low""" if not self.get_visible(): # If not visible, do aggressive cleanup self._cleanup_when_hidden() return True # Continue timer - + # Light cleanup when visible import gc + gc.collect() return True # Continue timer - + def _ensure_music_widget(self): """Lazy load music widget content""" if not self._music_initialized: - self._music_widget_content = PlayerBoxStack(get_shared_mpris_manager(), control_center=self) + self._music_widget_content = PlayerBoxStack( + get_shared_mpris_manager(), control_center=self + ) # Add to the music widget's children list current_children = list(self.music_widget.children) current_children.append(self._music_widget_content) @@ -478,7 +494,9 @@ class ModusControlCenter(Window): ), ], ) - self.bluetooth_center_box = CenterBox(start_children=[self.bluetooth_widgets]) + self.bluetooth_center_box = CenterBox( + start_children=[self.bluetooth_widgets] + ) self.bluetooth_center_box.set_size_request(300, -1) def _ensure_wifi_widgets(self): @@ -515,7 +533,7 @@ class ModusControlCenter(Window): if self.per_app_volume_widgets is None: if self._per_app_volume_widget is None: self._per_app_volume_widget = PerAppVolumeControl(self) - + self.per_app_volume_widgets = Box( orientation="vertical", h_expand=True, @@ -524,7 +542,9 @@ class ModusControlCenter(Window): self._per_app_volume_widget, ], ) - self.per_app_volume_center_box = CenterBox(start_children=[self.per_app_volume_widgets]) + self.per_app_volume_center_box = CenterBox( + start_children=[self.per_app_volume_widgets] + ) self.per_app_volume_center_box.set_size_request(300, -1) def _ensure_expanded_player_widgets(self): @@ -532,7 +552,7 @@ class ModusControlCenter(Window): if self.expanded_player_widgets is None: if self._expanded_player_widget is None: self._expanded_player_widget = EmbeddedExpandedPlayer(self) - + self.expanded_player_widgets = Box( orientation="vertical", h_expand=True, @@ -541,7 +561,9 @@ class ModusControlCenter(Window): self._expanded_player_widget, ], ) - self.expanded_player_center_box = CenterBox(start_children=[self.expanded_player_widgets]) + self.expanded_player_center_box = CenterBox( + start_children=[self.expanded_player_widgets] + ) self.expanded_player_center_box.set_size_request(300, -1) def set_dont_disturb(self, *_): @@ -654,7 +676,7 @@ class ModusControlCenter(Window): if visible: # Lazy load music widget when becoming visible self._ensure_music_widget() - + self.set_visible(visible) if not visible: self.close_bluetooth() @@ -744,7 +766,7 @@ class ModusControlCenter(Window): def hide_controlcenter(self, *_): self._mousecapture_parent.toggle_mousecapture() self.set_visible(False) - + def destroy(self): """Clean up resources when widget is destroyed""" # Disconnect all signal connections @@ -753,7 +775,7 @@ class ModusControlCenter(Window): connection.disconnect() except: pass - + # Clean up heavy components if self._wifi_man: self._wifi_man.destroy() @@ -765,5 +787,5 @@ class ModusControlCenter(Window): self._per_app_volume_widget.destroy() if self._expanded_player_widget: self._expanded_player_widget.destroy() - + super().destroy() diff --git a/modules/controlcenter/per_app_volume.py b/modules/controlcenter/per_app_volume.py index 8e81a00..ca25f7a 100644 --- a/modules/controlcenter/per_app_volume.py +++ b/modules/controlcenter/per_app_volume.py @@ -15,54 +15,45 @@ from utils.roam import audio_service class PerAppVolumeControl(Box): """Per-application volume control widget""" - + def __init__(self, control_center, **kwargs): super().__init__( orientation="vertical", name="per-app-volume-control", style_classes="menu", spacing=5, - **kwargs + **kwargs, ) - + self.control_center = control_center self._updating_volumes = set() self._app_widgets = {} self._signal_connections = [] - - # Header with back button - header = Box( - orientation="horizontal", - spacing=10, - children=[ - Button( - label="← Back", - name="back-button", - on_clicked=self._go_back, - ), - Label( - label="App Volume", - style_classes="title", - h_expand=True, - h_align="start" - ), - ] - ) - + + # Header with back button (hidden for cleaner UI) + # Scrollable container for app volume controls self.apps_container = Box( orientation="vertical", + name="apps-scrolled-container", spacing=5, ) - + self.scrolled_window = ScrolledWindow( + name="apps-scrolled-window", child=self.apps_container, - min_content_size=(280, 200), - max_content_size=(280, 300), + size=(300, 500), ) - - self.children = [header, self.scrolled_window] - + + # Add escape key binding for navigation back + try: + if hasattr(self.control_center, "add_keybinding"): + self.control_center.add_keybinding("Escape", self._go_back) + except Exception: + pass # Ignore if keybinding fails + + self.children = [self.scrolled_window] + # Connect to audio service changes if audio_service: self._signal_connections.append( @@ -71,10 +62,10 @@ class PerAppVolumeControl(Box): self._signal_connections.append( audio_service.connect("stream-removed", self._on_stream_changed) ) - + # Initial population self._populate_apps() - + # Set up auto-refresh timer for PulseAudio sinks self._refresh_timer = GLib.timeout_add_seconds(2, self._auto_refresh) @@ -82,7 +73,7 @@ class PerAppVolumeControl(Box): """Auto-refresh the application list every 2 seconds""" self._populate_apps() return True # Continue the timer - + def _go_back(self, *_): """Return to main control center view""" self.control_center.close_per_app_volume() @@ -90,38 +81,41 @@ class PerAppVolumeControl(Box): def _get_pulse_sinks(self): """Get PulseAudio sink inputs (application audio streams)""" try: - result = subprocess.run([ - 'pactl', 'list', 'sink-inputs' - ], capture_output=True, text=True, check=True) - + result = subprocess.run( + ["pactl", "list", "sink-inputs"], + capture_output=True, + text=True, + check=True, + ) + sinks = [] current_sink = {} - - for line in result.stdout.split('\n'): + + for line in result.stdout.split("\n"): line = line.strip() - - if line.startswith('Sink Input #'): + + if line.startswith("Sink Input #"): if current_sink: sinks.append(current_sink) current_sink = { - 'index': line.split('#')[1], - 'name': 'Unknown', - 'volume_raw': 100 + "index": line.split("#")[1], + "name": "Unknown", + "volume_raw": 100, } - elif 'application.name = ' in line: - current_sink['name'] = line.split('= ')[1].strip('"') - elif 'Volume:' in line and 'front-left:' in line: + elif "application.name = " in line: + current_sink["name"] = line.split("= ")[1].strip('"') + elif "Volume:" in line and "front-left:" in line: parts = line.split() for part in parts: - if part.endswith('%'): - current_sink['volume_raw'] = int(part.replace('%', '')) + if part.endswith("%"): + current_sink["volume_raw"] = int(part.replace("%", "")) break - + if current_sink: sinks.append(current_sink) - + return sinks - + except (subprocess.CalledProcessError, FileNotFoundError): return [] @@ -129,25 +123,25 @@ class PerAppVolumeControl(Box): """Format application name with proper capitalization""" if not name: return "Unknown" - + # Handle common app names specially special_names = { - 'spotify': 'Spotify', - 'firefox': 'Firefox', - 'chromium': 'Chromium', - 'chrome': 'Chrome', - 'vlc': 'VLC', - 'discord': 'Discord', - 'steam': 'Steam', - 'zen': 'Zen Browser', - 'code': 'VS Code', - 'telegram': 'Telegram' + "spotify": "Spotify", + "firefox": "Firefox", + "chromium": "Chromium", + "chrome": "Chrome", + "vlc": "VLC", + "discord": "Discord", + "steam": "Steam", + "zen": "Zen Browser", + "code": "VS Code", + "telegram": "Telegram", } - + name_lower = name.lower() if name_lower in special_names: return special_names[name_lower] - + # Default: capitalize first letter return name.capitalize() @@ -156,29 +150,33 @@ class PerAppVolumeControl(Box): # Clear existing widgets self.apps_container.children = [] self._app_widgets.clear() - + # Try Fabric audio service first applications = [] - if audio_service and hasattr(audio_service, 'applications'): + if audio_service and hasattr(audio_service, "applications"): applications = audio_service.applications - + # If no Fabric applications, try PulseAudio directly if not applications: pulse_sinks = self._get_pulse_sinks() - + if pulse_sinks: for sink in pulse_sinks: app_widget = self._create_pulse_app_control(sink) - self.apps_container.children = list(self.apps_container.children) + [app_widget] - self._app_widgets[sink['name']] = (app_widget, sink) + self.apps_container.children = list( + self.apps_container.children + ) + [app_widget] + self._app_widgets[sink["name"]] = (app_widget, sink) return - + # Use Fabric applications if available if applications: for app in applications: - if hasattr(app, 'name') and hasattr(app, 'volume'): + if hasattr(app, "name") and hasattr(app, "volume"): app_widget = self._create_app_control(app) - self.apps_container.children = list(self.apps_container.children) + [app_widget] + self.apps_container.children = list( + self.apps_container.children + ) + [app_widget] self._app_widgets[app.name] = (app_widget, app) else: self._show_no_apps_message() @@ -196,13 +194,13 @@ class PerAppVolumeControl(Box): def _create_pulse_app_control(self, sink): """Create volume control for a PulseAudio sink input""" # Format and truncate app name - app_name = self._format_app_name(sink['name']) + app_name = self._format_app_name(sink["name"]) if len(app_name) > 20: app_name = app_name[:17] + "..." - + # Volume scale with Apple-like styling volume_scale = Scale( - value=sink['volume_raw'], + value=sink["volume_raw"], min_value=0, max_value=150, increments=(5, 5), @@ -210,10 +208,15 @@ class PerAppVolumeControl(Box): size=28, h_expand=True, ) - + # Connect volume change handler - volume_scale.connect("change-value", lambda scale, scroll_type, value, sink_data=sink: self._set_pulse_volume(sink_data, value)) - + volume_scale.connect( + "change-value", + lambda scale, scroll_type, value, sink_data=sink: self._set_pulse_volume( + sink_data, value + ), + ) + # Create the app control widget app_control = Box( orientation="vertical", @@ -226,9 +229,9 @@ class PerAppVolumeControl(Box): h_align="start", ), volume_scale, - ] + ], ) - + return app_control def _create_app_control(self, app): @@ -237,7 +240,7 @@ class PerAppVolumeControl(Box): app_name = self._format_app_name(app.name) if len(app_name) > 20: app_name = app_name[:17] + "..." - + # Handle different volume formats volume_value = app.volume if isinstance(volume_value, float): @@ -252,7 +255,7 @@ class PerAppVolumeControl(Box): volume_percent = min(max(volume_value, 0), 100) else: volume_percent = 50 - + # Volume scale with Apple-like styling volume_scale = Scale( value=volume_percent, @@ -263,10 +266,13 @@ class PerAppVolumeControl(Box): size=28, h_expand=True, ) - + # Connect volume change handler - volume_scale.connect("change-value", lambda scale, scroll_type, value, app=app: self._set_app_volume(app, value)) - + volume_scale.connect( + "change-value", + lambda scale, scroll_type, value, app=app: self._set_app_volume(app, value), + ) + # Create the app control widget app_control = Box( orientation="vertical", @@ -279,38 +285,43 @@ class PerAppVolumeControl(Box): h_align="start", ), volume_scale, - ] + ], ) - + return app_control def _set_pulse_volume(self, sink, volume_percent): """Set volume for a PulseAudio sink input""" - if sink['name'] in self._updating_volumes: + if sink["name"] in self._updating_volumes: return - - self._updating_volumes.add(sink['name']) - + + self._updating_volumes.add(sink["name"]) + try: - subprocess.run([ - 'pactl', 'set-sink-input-volume', - sink['index'], f'{int(volume_percent)}%' - ], check=True) + subprocess.run( + [ + "pactl", + "set-sink-input-volume", + sink["index"], + f"{int(volume_percent)}%", + ], + check=True, + ) except subprocess.CalledProcessError: pass finally: - GLib.timeout_add(100, lambda: self._updating_volumes.discard(sink['name'])) + GLib.timeout_add(100, lambda: self._updating_volumes.discard(sink["name"])) def _set_app_volume(self, app, volume_percent): """Set volume for a specific application (Fabric)""" if app.name in self._updating_volumes: return - + self._updating_volumes.add(app.name) - + try: current_volume = app.volume - + if isinstance(current_volume, float): if 0.0 <= current_volume <= 1.0: volume_value = volume_percent / 100.0 @@ -323,34 +334,35 @@ class PerAppVolumeControl(Box): volume_value = int(volume_percent) else: volume_value = volume_percent - + app.volume = volume_value except Exception: pass finally: GLib.timeout_add(100, lambda: self._updating_volumes.discard(app.name)) - + def _on_stream_changed(self, *_): """Handle when audio streams are added or removed""" GLib.idle_add(self._populate_apps) - + def refresh(self): """Manually refresh the application list""" self._populate_apps() - + def destroy(self): """Clean up resources""" - if hasattr(self, '_refresh_timer'): + if hasattr(self, "_refresh_timer"): GLib.source_remove(self._refresh_timer) - + for connection in self._signal_connections: try: connection.disconnect() except: pass - + self._signal_connections.clear() self._app_widgets.clear() self._updating_volumes.clear() - - super().destroy() \ No newline at end of file + + super().destroy() + diff --git a/styles/controlcenter.css b/styles/controlcenter.css index c7fdd29..5468044 100644 --- a/styles/controlcenter.css +++ b/styles/controlcenter.css @@ -321,13 +321,19 @@ /* Per-app volume control styles */ #per-app-volume-control { - margin: 5px; + margin: 0px; background-color: alpha(#999, 0.1); + min-height: 140px; + min-width: 140px; border: 0.5px solid alpha(#000, 0.4); border-radius: 8px; - padding: 1rem; + /* padding: 1rem; */ } +#apps-scrolled-container { + min-height: 120px; + min-width: 120px; +} #back-button { padding: 8px 12px; background-color: alpha(#fff, 0.1); @@ -345,7 +351,7 @@ /* Apple-style app volume items */ .apple-app-volume-item { - margin: 12px 0; + margin: 3px 0; padding: 16px; background-color: alpha(#fff, 0.08); border-radius: 12px; @@ -361,14 +367,14 @@ font-size: 14px; font-weight: 600; color: #fff; - margin-bottom: 8px; + margin-bottom: 4px; letter-spacing: -0.01em; } /* Apple-style volume slider */ #apple-volume-slider { background-color: transparent; - margin-top: 8px; + margin-top: 4px; margin-bottom: 4px; min-height: 32px; } @@ -391,11 +397,11 @@ #apple-volume-slider slider { background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); - border: 2px solid alpha(#007aff, 0.8); + /* border: 2px solid alpha(#007aff, 0.8); */ border-radius: 50%; min-width: 20px; min-height: 20px; - margin: -7px; + margin: -10px; box-shadow: 0 2px 8px alpha(#000, 0.15), 0 1px 3px alpha(#000, 0.2); @@ -410,14 +416,14 @@ 0 2px 6px alpha(#007aff, 0.3); min-width: 22px; min-height: 22px; - margin: -8px; + margin: -9px; } #apple-volume-slider slider:active { background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); min-width: 21px; min-height: 21px; - margin: -7.5px; + margin: -9px; box-shadow: 0 2px 6px alpha(#000, 0.25), 0 1px 3px alpha(#007aff, 0.4); @@ -441,7 +447,7 @@ #app-volume-slider { background-color: transparent; - margin-top: 5px; + /* margin-top: 5px; */ } #app-volume-slider trough { @@ -468,18 +474,14 @@ /* Circle button for per-app volume */ #per-app-volume-button { background-color: alpha(#fff, 0.15); - border: 2px solid alpha(#007aff, 0.6); + border: 2px solid alpha(#999, 0.6); border-radius: 50%; - min-width: 32px; - min-height: 32px; - padding: 0; - transition: all 0.2s ease; - box-shadow: 0 2px 6px alpha(#000, 0.15); + min-width: 30px; } #per-app-volume-button:hover { background-color: alpha(#fff, 0.25); - border-color: #007aff; + /* border-color: #007aff; */ box-shadow: 0 4px 12px alpha(#000, 0.2); } @@ -489,10 +491,10 @@ } #per-app-volume-icon { - font-size: 14px; - font-weight: bold; - color: #007aff; - text-shadow: none; + /* font-size: 14px; */ + /* font-weight: bold; */ + /* color: #007aff; */ + /* text-shadow: none; */ } /* Apple-style seek bar for expanded player */