from fabric.utils import GLib from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.scale import Scale from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.widgets.separator import Separator # 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", spacing=5, **kwargs, ) self.control_center = control_center self._updating_volumes = set() self._app_widgets = {} self._signal_connections = [] # Header with back button self.header = Box( orientation="horizontal", name="per-app-volume-header", style_classes="menu-header", children=[ Button( image=Image(icon_name="back", size=10), on_clicked=lambda *_: self.control_center.close_per_app_volume(), ), Label("App Volume", name="app-volume-header"), ], ) # Scrollable container for app volume controls self.apps_container = Box( orientation="vertical", name="apps-scrolled-container", spacing=2, ) self.scrolled_window = ScrolledWindow( name="apps-scrolled-window", child=self.apps_container, size=(300, 500), ) # 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.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 audio streams 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_app_icon(self, app): """Get icon for application""" # Don't use fabric's generic audio icon, use description/name for better detection fabric_icon = getattr(app, "icon_name", "") if fabric_icon and fabric_icon != "audio": return fabric_icon # Use description first (more accurate), then name app_description = getattr(app, "description", "").lower() app_name = app.name.lower() # Check description first as it's more reliable search_text = app_description if app_description else app_name icon_mapping = { "spotify": "spotify", "firefox": "firefox", "chromium": "chromium-browser", "chrome": "google-chrome", "vlc": "vlc", "discord": "discord", "steam": "steam", "zen": "zen-browser", "code": "vscode", "visual studio code": "vscode", "telegram": "telegram-desktop", "pulse": "audio-card", "pipewire": "audio-card", "alsa": "audio-card", "sink": "audio-speakers", "source": "audio-input-microphone", "youtube": "youtube", "music": "rhythmbox", "media": "multimedia-player", } # Check if any key in mapping is contained in search text for key, icon in icon_mapping.items(): if key in search_text: return icon # Also check app name as fallback if search_text != app_name: for key, icon in icon_mapping.items(): if key in app_name: return icon # Default audio icon for unknown apps return "audio-volume-high" 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() # Use fabric audio service for applications if not audio_service: self._show_no_apps_message() return applications = getattr(audio_service, "applications", []) if applications: for i, app in enumerate(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] # Add separator between apps (except for last one) if i < len(applications) - 1: separator = Separator( orientation="horizontal", style_classes="app-volume-separator", ) self.apps_container.children = list( self.apps_container.children ) + [separator] 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_app_control(self, app): """Create compact volume control for a single application""" # Format app name (shorter for compact layout) app_name = self._format_app_name(app.name) # Get current volume from fabric audio service current_volume = getattr(app, "volume", 0.0) max_vol = getattr(audio_service, "max_volume", 100) if audio_service else 100 # Ensure volume is in valid range volume_percent = max(0, min(current_volume, max_vol)) # App icon - use the same approach as expanded player icon_name = self._get_app_icon(app) app_icon = Image( icon_name=icon_name, name="app-icon", icon_size=16, style_classes="app-icon", ) # App name label name_label = Label( label=app_name, style_classes="app-name-compact", justification="left", h_align="start", max_chars_width=10, ellipsization="end", ) # Volume scale (smaller and horizontal) volume_scale = Scale( value=volume_percent, min_value=0, max_value=max_vol, increments=(1, 5), name="compact-volume-slider", size=20, 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 horizontal compact layout app_control = Box( orientation="horizontal", spacing=5, h_expand=True, style_classes="compact-app-volume-item", children=[ Box( orientation="h", spacing=3, v_expand=True, v_align="center", name="app-control-box", children=[ app_icon, name_label, ], ), volume_scale, ], ) # Add separator after the control return app_control def _set_app_volume(self, app, volume_value): """Set volume for a specific application using fabric audio service""" if app.name in self._updating_volumes: return self._updating_volumes.add(app.name) try: # Get max volume from audio service max_vol = ( getattr(audio_service, "max_volume", 100) if audio_service else 100 ) # Ensure volume is within bounds volume_value = max(0, min(volume_value, max_vol)) # Set volume directly - fabric expects the actual volume value, not percentage app.volume = volume_value except Exception as e: print(f"Error setting volume for {app.name}: {e}") 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) # Disconnect fabric audio service signals if audio_service: for connection in self._signal_connections: try: audio_service.disconnect(connection) except Exception: pass self._signal_connections.clear() self._app_widgets.clear() self._updating_volumes.clear() super().destroy()