Per App Volume Control Enhanced

This commit is contained in:
tr1x_em committed 2025-08-06 20:58:18 +05:30
1 parent f1993ef473
commit 2575814554
4 files changed
+226 -182

No files matched your search

+9 -1
View File
@@ -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
+73 -51
View File
@@ -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()
+121 -109
View File
@@ -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()
super().destroy()
+23 -21
View File
@@ -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 */