From c7d40f0177fc3f533afe0579cef325e8ef372ddb Mon Sep 17 00:00:00 2001 From: tr1x_em Date: Wed, 6 Aug 2025 14:07:49 +0530 Subject: [PATCH] Per app sound control --- AGENTS.md | 25 +- modules/controlcenter/main.py | 55 +++- modules/controlcenter/per_app_volume.py | 356 ++++++++++++++++++++++++ modules/notification/notification.py | 21 +- services/custom_notification.py | 10 +- styles/controlcenter.css | 158 +++++++++++ 6 files changed, 600 insertions(+), 25 deletions(-) create mode 100644 modules/controlcenter/per_app_volume.py diff --git a/AGENTS.md b/AGENTS.md index 1a0e8b5..c356b58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,22 +4,23 @@ - **Technology**: Python desktop environment using Fabric framework (GTK/PyGObject) - **Purpose**: Wayland desktop environment with launcher, panel, dock, notifications - **Entry Point**: `main.py` - starts all components +- **Dependencies**: PyGObject, loguru, pillow, psutil, pydbus - see requirements.txt ## Build/Test Commands - **Run Application**: `python main.py` - **Test Icon Browser**: `python test.py` - **Install Dependencies**: `pip install -r requirements.txt` -- **No formal test suite exists** - test by running components directly +- **No formal test suite exists** - test by running individual modules/components directly ## Code Style Guidelines -- **Imports**: Standard library first, third-party second, local modules last with blank lines between groups -- **Formatting**: Follow PEP 8, use 4 spaces for indentation -- **Naming**: snake_case for functions/variables, PascalCase for classes, UPPER_CASE for constants -- **Type Hints**: Use typing imports for List, Optional, Tuple annotations -- **Error Handling**: Use try/except blocks, log errors with print statements or loguru logger -- **Comments**: Minimal docstrings for classes, no inline comments unless complex logic -- **Constants**: Define module-level constants at top (e.g., SEARCH_DEBOUNCE_MS = 50) -- **Properties**: Use @Property decorator with type hints for Fabric service properties -- **Signals**: Use @Signal decorator for event handling in services -- **Widget Structure**: Inherit from appropriate Fabric widgets (Window, Box, Entry, etc.) -- **GTK Integration**: Use gi.repository imports for GTK, GLib functionality \ No newline at end of file +- **Import Order**: Standard library first, third-party (gi, fabric), local modules last with blank lines +- **Import Comments**: Use `# Standard library imports`, `# Fabric imports`, `# Local imports` headers +- **Formatting**: PEP 8, 4 spaces indentation, line length ~100 chars +- **Naming**: snake_case functions/variables, PascalCase classes, UPPER_CASE constants +- **Type Hints**: Use modern typing (list[str] over List[str]), Optional for nullable params +- **Error Handling**: try/except with contextlib.suppress for optional operations, log with loguru +- **Documentation**: Brief docstrings for classes, avoid inline comments unless complex +- **Constants**: Module-level constants at top after imports (APP_NAME, ALLOWED_PLAYERS) +- **Services**: Use @Property/@Signal decorators, inherit from fabric.core.service.Service +- **Widgets**: Inherit from fabric.widgets (Window, Box, Entry), use **kwargs forwarding +- **GTK**: Always use gi.require_version before imports, handle missing dependencies gracefully \ No newline at end of file diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py index 77dc454..3675f5b 100644 --- a/modules/controlcenter/main.py +++ b/modules/controlcenter/main.py @@ -16,6 +16,7 @@ from services.brightness import Brightness from utils.roam import audio_service, modus_service from widgets.wayland import WaylandWindow as Window from modules.controlcenter.player import PlayerBoxStack +from modules.controlcenter.per_app_volume import PerAppVolumeControl from services.mpris import MprisPlayerManager brightness_service = Brightness.get_initial() @@ -45,12 +46,14 @@ class ModusControlCenter(Window): self._wifi_initialized = False self._bluetooth_initialized = False self._music_initialized = False + self._per_app_volume_initialized = False # Store references for cleanup self._signal_connections = [] self._wifi_man = None self._bluetooth_man = None self._music_widget_content = None + self._per_app_volume_widget = None self.add_keybinding("Escape", self.hide_controlcenter) @@ -128,6 +131,7 @@ class ModusControlCenter(Window): self.has_bluetooth_open = False self.has_wifi_open = False + self.has_per_app_volume_open = False self.bluetooth_svg = Svg( name="bluetooth-icon", @@ -269,10 +273,19 @@ class ModusControlCenter(Window): style_classes="menu", h_expand=True, children=[ - Label(label="Sound", style_classes="title", h_align="start"), - self.volume_scale, - Label( - label=" ", name="brightness-widget-icon", h_align="start" + Button( + name="volume-widget-button", + child=Box( + orientation="vertical", + children=[ + Label(label="Sound", style_classes="title", h_align="start"), + self.volume_scale, + Label( + label=" ", name="brightness-widget-icon", h_align="start" + ), + ] + ), + on_clicked=self.open_per_app_volume, ), ], ), @@ -288,10 +301,12 @@ class ModusControlCenter(Window): # Lazy-loaded widgets - create placeholders self.bluetooth_widgets = None self.wifi_widgets = None + self.per_app_volume_widgets = None self.center_box = CenterBox(start_children=[self.widgets]) self.bluetooth_center_box = None self.wifi_center_box = None + self.per_app_volume_center_box = None self.widgets.set_size_request(300, -1) @@ -393,6 +408,23 @@ class ModusControlCenter(Window): self.wifi_center_box = CenterBox(start_children=[self.wifi_widgets]) self.wifi_center_box.set_size_request(300, -1) + def _ensure_per_app_volume_widgets(self): + """Lazy load per-app volume widgets""" + 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, + name="control-center-widgets", + children=[ + 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.set_size_request(300, -1) + def set_dont_disturb(self, *_): self.focus_mode = not self.focus_mode modus_service.dont_disturb = self.focus_mode @@ -475,6 +507,18 @@ class ModusControlCenter(Window): idle_add(lambda *_: self.set_children(self.center_box)) self.has_wifi_open = False + def open_per_app_volume(self, *_): + self._ensure_per_app_volume_widgets() + idle_add(lambda *_: self.set_children(self.per_app_volume_center_box)) + self.has_per_app_volume_open = True + # Refresh the app list when opening + if self._per_app_volume_widget: + self._per_app_volume_widget.refresh() + + def close_per_app_volume(self, *_): + idle_add(lambda *_: self.set_children(self.center_box)) + self.has_per_app_volume_open = False + def _set_mousecapture(self, visible: bool): if visible: # Lazy load music widget when becoming visible @@ -484,6 +528,7 @@ class ModusControlCenter(Window): if not visible: self.close_bluetooth() self.close_wifi() + self.close_per_app_volume() def volume_changed( self, @@ -584,5 +629,7 @@ class ModusControlCenter(Window): self._bluetooth_man.destroy() if self._music_widget_content: self._music_widget_content.destroy() + if self._per_app_volume_widget: + self._per_app_volume_widget.destroy() super().destroy() diff --git a/modules/controlcenter/per_app_volume.py b/modules/controlcenter/per_app_volume.py new file mode 100644 index 0000000..8e81a00 --- /dev/null +++ b/modules/controlcenter/per_app_volume.py @@ -0,0 +1,356 @@ +# Standard library imports +import subprocess +from gi.repository import GLib + +# Fabric imports +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from fabric.widgets.scale import Scale +from fabric.widgets.scrolledwindow import ScrolledWindow + +# Local imports +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 + ) + + 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" + ), + ] + ) + + # Scrollable container for app volume controls + self.apps_container = Box( + orientation="vertical", + spacing=5, + ) + + self.scrolled_window = ScrolledWindow( + child=self.apps_container, + min_content_size=(280, 200), + max_content_size=(280, 300), + ) + + self.children = [header, self.scrolled_window] + + # Connect to audio service changes + if audio_service: + self._signal_connections.append( + audio_service.connect("stream-added", self._on_stream_changed) + ) + 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) + + def _auto_refresh(self): + """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() + + 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) + + sinks = [] + current_sink = {} + + for line in result.stdout.split('\n'): + line = line.strip() + + if line.startswith('Sink Input #'): + if current_sink: + sinks.append(current_sink) + current_sink = { + '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: + parts = line.split() + for part in parts: + 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 [] + + def _format_app_name(self, name): + """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' + } + + name_lower = name.lower() + if name_lower in special_names: + return special_names[name_lower] + + # Default: capitalize first letter + return name.capitalize() + + def _populate_apps(self): + """Populate the widget with current audio applications""" + # 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'): + 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) + return + + # Use Fabric applications if available + if applications: + for app in applications: + 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._app_widgets[app.name] = (app_widget, app) + else: + self._show_no_apps_message() + + def _show_no_apps_message(self): + """Show message when no apps are playing audio""" + message = Label( + label="No applications currently using audio", + style_classes="subtitle", + h_align="center", + v_align="center", + ) + self.apps_container.children = [message] + + 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']) + if len(app_name) > 20: + app_name = app_name[:17] + "..." + + # Volume scale with Apple-like styling + volume_scale = Scale( + value=sink['volume_raw'], + min_value=0, + max_value=150, + increments=(5, 5), + name="apple-volume-slider", + 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)) + + # Create the app control widget + app_control = Box( + orientation="vertical", + spacing=8, + style_classes="apple-app-volume-item", + children=[ + Label( + label=app_name, + style_classes="apple-app-name", + h_align="start", + ), + volume_scale, + ] + ) + + return app_control + + def _create_app_control(self, app): + """Create volume control for a single application (Fabric)""" + # Format and truncate app name + 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): + if 0.0 <= volume_value <= 1.0: + volume_percent = volume_value * 100 + else: + volume_percent = min(max(volume_value, 0), 100) + elif isinstance(volume_value, int): + if volume_value <= 1: + volume_percent = volume_value * 100 + else: + volume_percent = min(max(volume_value, 0), 100) + else: + volume_percent = 50 + + # Volume scale with Apple-like styling + volume_scale = Scale( + value=volume_percent, + min_value=0, + max_value=100, + increments=(5, 5), + name="apple-volume-slider", + 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)) + + # Create the app control widget + app_control = Box( + orientation="vertical", + spacing=8, + style_classes="apple-app-volume-item", + children=[ + Label( + label=app_name, + style_classes="apple-app-name", + 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: + return + + self._updating_volumes.add(sink['name']) + + try: + 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'])) + + 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 + else: + volume_value = volume_percent + elif isinstance(current_volume, int): + if current_volume <= 1: + volume_value = int(volume_percent / 100.0) + else: + 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'): + 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 diff --git a/modules/notification/notification.py b/modules/notification/notification.py index d041117..174a403 100644 --- a/modules/notification/notification.py +++ b/modules/notification/notification.py @@ -138,13 +138,7 @@ class NotificationWidget(Box): Box( name="notification-image", children=CustomImage( - pixbuf=( - notification.image_pixbuf.scale_simple( - 35, 35, GdkPixbuf.InterpType.BILINEAR - ) - if notification.image_pixbuf - else self.get_pixbuf(notification.app_icon, 35, 35) - ) + pixbuf=self._get_notification_pixbuf(notification) ), ), Box( @@ -232,6 +226,19 @@ class NotificationWidget(Box): logger.error(f"Failed to load or scale icon: {e}") return None + def _get_notification_pixbuf(self, notification): + """Safely get notification pixbuf, fallback to app icon if needed""" + try: + if hasattr(notification, 'image_pixbuf') and notification.image_pixbuf: + return notification.image_pixbuf.scale_simple( + 35, 35, GdkPixbuf.InterpType.BILINEAR + ) + except Exception as e: + logger.warning(f"Failed to get notification image: {e}") + + # Fallback to app icon + return self.get_pixbuf(notification.app_icon, 35, 35) + def create_action_buttons(self, notification): return Box( name="notification-action-buttons", diff --git a/services/custom_notification.py b/services/custom_notification.py index 12bafa9..d7673bd 100644 --- a/services/custom_notification.py +++ b/services/custom_notification.py @@ -1,7 +1,9 @@ +# Standard library imports import json import os from typing import List +# Fabric imports import gi from gi.repository import GdkPixbuf @@ -84,8 +86,12 @@ class CachedNotification(Service): def image_pixbuf(self) -> GdkPixbuf.Pixbuf: if self.image_pixmap: return self.image_pixmap.as_pixbuf() - if self.image_file: - return GdkPixbuf.Pixbuf.new_from_file(self.image_file) + if self.image_file and os.path.exists(self.image_file): + try: + return GdkPixbuf.Pixbuf.new_from_file(self.image_file) + except Exception: + # If file can't be loaded, return None + pass return None # type: ignore @Property(dict, "readable") diff --git a/styles/controlcenter.css b/styles/controlcenter.css index 4a41991..aa677a8 100644 --- a/styles/controlcenter.css +++ b/styles/controlcenter.css @@ -320,3 +320,161 @@ background-image: none; color: alpha(#fff, 0.2); } + +/* Per-app volume control styles */ +#per-app-volume-control { + margin: 5px; + background-color: alpha(#999, 0.1); + border: 0.5px solid alpha(#000, 0.4); + border-radius: 8px; + padding: 1rem; +} + +#back-button { + padding: 8px 12px; + background-color: alpha(#fff, 0.1); + border-radius: 8px; + border: none; + color: #fff; + font-size: 13px; + font-weight: 500; + transition: background-color 0.2s ease; +} + +#back-button:hover { + background-color: alpha(#fff, 0.18); +} + +/* Apple-style app volume items */ +.apple-app-volume-item { + margin: 12px 0; + padding: 16px; + background-color: alpha(#fff, 0.08); + border-radius: 12px; + border: 1px solid alpha(#fff, 0.12); + transition: background-color 0.2s ease; +} + +.apple-app-volume-item:hover { + background-color: alpha(#fff, 0.12); +} + +.apple-app-name { + font-size: 14px; + font-weight: 600; + color: #fff; + margin-bottom: 8px; + letter-spacing: -0.01em; +} + +/* Apple-style volume slider */ +#apple-volume-slider { + background-color: transparent; + margin-top: 8px; + margin-bottom: 4px; + min-height: 32px; +} + +#apple-volume-slider trough { + min-width: 28px; + min-height: 6px; + border-radius: 3px; + background-color: alpha(#fff, 0.25); + border: none; + box-shadow: inset 0 1px 2px alpha(#000, 0.2); +} + +#apple-volume-slider highlight { + background: linear-gradient(90deg, #007aff 0%, #0051d0 100%); + border-radius: 3px; + border: none; + box-shadow: 0 1px 3px alpha(#007aff, 0.3); +} + +#apple-volume-slider slider { + background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); + border: 2px solid alpha(#007aff, 0.8); + border-radius: 50%; + min-width: 20px; + min-height: 20px; + margin: -7px; + box-shadow: + 0 2px 8px alpha(#000, 0.15), + 0 1px 3px alpha(#000, 0.2); + transition: all 0.15s ease; +} + +#apple-volume-slider slider:hover { + background: linear-gradient(135deg, #ffffff 0%, #f0f1f2 100%); + border-color: #007aff; + box-shadow: + 0 4px 12px alpha(#000, 0.2), + 0 2px 6px alpha(#007aff, 0.3); + min-width: 22px; + min-height: 22px; + margin: -8px; +} + +#apple-volume-slider slider:active { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + min-width: 21px; + min-height: 21px; + margin: -7.5px; + box-shadow: + 0 2px 6px alpha(#000, 0.25), + 0 1px 3px alpha(#007aff, 0.4); +} + +/* Legacy app volume styles (fallback) */ +.app-volume-item { + margin: 8px 0; + padding: 8px; + background-color: alpha(#fff, 0.05); + border-radius: 6px; + border: 1px solid alpha(#fff, 0.1); +} + +.app-name { + font-size: 13px; + font-weight: 500; + color: #fff; + margin-bottom: 5px; +} + +#app-volume-slider { + background-color: transparent; + margin-top: 5px; +} + +#app-volume-slider trough { + min-width: 20px; + border-radius: 99px; + background-color: alpha(#666, 0.5); + border: 1px solid alpha(#444, 0.3); +} + +#app-volume-slider highlight { + background: alpha(#0078d4, 0.8); + border-radius: 99px; +} + +#app-volume-slider slider { + background-image: none; + background-color: #fff; + padding: 2px; + min-width: 4px; + min-height: 18px; + border-radius: 20px; +} + +#volume-widget-button { + background-color: transparent; + border: none; + padding: 0; + border-radius: 8px; + transition: background-color 0.2s ease; +} + +#volume-widget-button:hover { + background-color: alpha(#fff, 0.05); +}