Per app sound control
This commit is contained in:
1 parent
e354f23ad1
commit
c7d40f0177
6 files changed
+600
-25
No files matched your search
@@ -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
|
||||
- **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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user