- Sélecteur de sortie (SORTIE) : liste verticale des sinks disponibles, filtre les SUSPENDED (HDMI non branchés), actif surligné en rose - Sélecteur d'entrée (ENTRÉE) : même logique, filtre les .monitor (loopbacks), garde les vrais micros même SUSPENDED - Popup ancré à droite (plus jamais hors écran) - LANG=C pour pactl (indépendant de la locale système)
679 lines
24 KiB
Python
Executable File
679 lines
24 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# vc-media-popup.py — Popup audio + luminosité violet-chaton
|
|
# Lancé depuis le clic sur wireplumber OU backlight
|
|
|
|
import gi
|
|
gi.require_version('Gtk', '3.0')
|
|
gi.require_version('GtkLayerShell', '0.1')
|
|
from gi.repository import Gtk, Gdk, GtkLayerShell, GLib
|
|
import subprocess
|
|
import os
|
|
import re
|
|
|
|
# ── CSS ───────────────────────────────────────────────────────────────────────
|
|
|
|
CSS = """
|
|
window {
|
|
background-color: rgba(52, 28, 74, 0.93);
|
|
border: 3px solid rgba(255, 121, 198, 0.78);
|
|
border-radius: 14px;
|
|
}
|
|
|
|
#container {
|
|
padding: 14px 20px 16px 20px;
|
|
}
|
|
|
|
/* ── Labels ────────────────────────────────────────────────────────────────── */
|
|
|
|
#section-label {
|
|
color: #ff79c6;
|
|
font-family: "JetBrainsMono Nerd Font";
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
#device-name {
|
|
color: rgba(248, 248, 242, 0.75);
|
|
font-family: "JetBrainsMono Nerd Font";
|
|
font-size: 11px;
|
|
}
|
|
|
|
#pct-label {
|
|
color: #f8f8f2;
|
|
font-family: "JetBrainsMono Nerd Font";
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
min-width: 38px;
|
|
}
|
|
|
|
#separator {
|
|
color: rgba(92, 73, 108, 0.50);
|
|
margin: 6px 0 4px 0;
|
|
}
|
|
|
|
/* ── Boutons mute (icônes cliquables) ───────────────────────────────────────── */
|
|
|
|
#mute-icon {
|
|
font-family: "JetBrainsMono Nerd Font";
|
|
font-size: 17px;
|
|
color: #ff79c6;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 0 4px;
|
|
min-width: 28px;
|
|
}
|
|
|
|
#mute-icon:hover {
|
|
background: rgba(255, 121, 198, 0.15);
|
|
}
|
|
|
|
#mute-icon.muted {
|
|
color: rgba(243, 139, 168, 0.70);
|
|
}
|
|
|
|
#mic-icon {
|
|
font-family: "JetBrainsMono Nerd Font";
|
|
font-size: 17px;
|
|
color: #ff79c6;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 0 4px;
|
|
min-width: 28px;
|
|
}
|
|
|
|
#mic-icon:hover {
|
|
background: rgba(255, 121, 198, 0.15);
|
|
}
|
|
|
|
#mic-icon.muted {
|
|
color: rgba(243, 139, 168, 0.70);
|
|
}
|
|
|
|
/* ── Sliders audio (rose) ───────────────────────────────────────────────────── */
|
|
|
|
scale.audio {
|
|
min-height: 22px;
|
|
}
|
|
|
|
scale.audio trough {
|
|
background-color: rgba(92, 73, 108, 0.55);
|
|
border-radius: 3px;
|
|
min-height: 5px;
|
|
border: none;
|
|
}
|
|
|
|
scale.audio trough highlight {
|
|
background-color: #ff79c6;
|
|
min-height: 6px;
|
|
border-radius: 3px;
|
|
border: none;
|
|
}
|
|
|
|
scale.audio.muted trough highlight {
|
|
background-color: rgba(108, 112, 134, 0.40);
|
|
}
|
|
|
|
scale.audio slider {
|
|
background-color: #f8f8f2;
|
|
border-radius: 50%;
|
|
min-width: 16px;
|
|
min-height: 16px;
|
|
border: 2px solid rgba(255, 121, 198, 0.80);
|
|
box-shadow: none;
|
|
transition: none;
|
|
}
|
|
|
|
scale.audio slider:hover {
|
|
background-color: #e79cfe;
|
|
border-color: #ff79c6;
|
|
}
|
|
|
|
scale.audio.muted slider {
|
|
border-color: rgba(108, 112, 134, 0.60);
|
|
}
|
|
|
|
/* ── Sélecteur de périphérique de sortie ────────────────────────────────────── */
|
|
|
|
#device-btn {
|
|
background-color: transparent;
|
|
color: rgba(248, 248, 242, 0.65);
|
|
font-family: "JetBrainsMono Nerd Font";
|
|
font-size: 11px;
|
|
border: 1px solid transparent;
|
|
border-radius: 8px;
|
|
padding: 5px 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
#device-btn:hover {
|
|
background-color: rgba(91, 70, 113, 0.55);
|
|
color: #f8f8f2;
|
|
border-color: rgba(92, 73, 108, 0.60);
|
|
}
|
|
|
|
#device-btn.active {
|
|
background-color: rgba(255, 121, 198, 0.14);
|
|
color: #ff79c6;
|
|
border-color: rgba(255, 121, 198, 0.55);
|
|
}
|
|
|
|
/* ── Slider luminosité (cyan) ───────────────────────────────────────────────── */
|
|
|
|
scale.bright {
|
|
min-height: 22px;
|
|
}
|
|
|
|
scale.bright trough {
|
|
background-color: rgba(92, 73, 108, 0.55);
|
|
border-radius: 3px;
|
|
min-height: 5px;
|
|
border: none;
|
|
}
|
|
|
|
scale.bright trough highlight {
|
|
background-color: #8be9fd;
|
|
min-height: 6px;
|
|
border-radius: 3px;
|
|
border: none;
|
|
}
|
|
|
|
scale.bright slider {
|
|
background-color: #f8f8f2;
|
|
border-radius: 50%;
|
|
min-width: 16px;
|
|
min-height: 16px;
|
|
border: 2px solid rgba(139, 233, 253, 0.80);
|
|
box-shadow: none;
|
|
transition: none;
|
|
}
|
|
|
|
scale.bright slider:hover {
|
|
background-color: #8be9fd;
|
|
border-color: #8be9fd;
|
|
}
|
|
|
|
#bright-icon {
|
|
color: #8be9fd;
|
|
font-family: "JetBrainsMono Nerd Font";
|
|
font-size: 17px;
|
|
min-width: 28px;
|
|
}
|
|
"""
|
|
|
|
POPUP_WIDTH = 310
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def run(cmd, env=None, **kw):
|
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=2,
|
|
env=env, **kw)
|
|
|
|
def get_sink_volume():
|
|
r = run(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'])
|
|
parts = r.stdout.strip().split()
|
|
vol = int(float(parts[1]) * 100) if len(parts) >= 2 else 50
|
|
return min(max(vol, 0), 100), '[MUTED]' in r.stdout
|
|
|
|
def get_source_volume():
|
|
r = run(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SOURCE@'])
|
|
parts = r.stdout.strip().split()
|
|
vol = int(float(parts[1]) * 100) if len(parts) >= 2 else 50
|
|
return min(max(vol, 0), 100), '[MUTED]' in r.stdout
|
|
|
|
def get_node_name(target):
|
|
r = run(['wpctl', 'inspect', target])
|
|
for field in ('node.description', 'node.nick'):
|
|
m = re.search(rf'{field}\s*=\s*"([^"]+)"', r.stdout)
|
|
if m:
|
|
return m.group(1)
|
|
return target
|
|
|
|
def set_sink_vol(v):
|
|
run(['wpctl', 'set-volume', '-l', '1.0', '@DEFAULT_AUDIO_SINK@', f'{v}%'])
|
|
_wob(f'v:{v}')
|
|
|
|
def set_source_vol(v):
|
|
run(['wpctl', 'set-volume', '@DEFAULT_AUDIO_SOURCE@', f'{v}%'])
|
|
_wob(f'v:{v}')
|
|
|
|
def toggle_sink_mute():
|
|
run(['wpctl', 'set-mute', '@DEFAULT_AUDIO_SINK@', 'toggle'])
|
|
|
|
def toggle_source_mute():
|
|
run(['wpctl', 'set-mute', '@DEFAULT_AUDIO_SOURCE@', 'toggle'])
|
|
|
|
def get_brightness():
|
|
r = run(['brightnessctl', 'info'])
|
|
m = re.search(r'\((\d+)%\)', r.stdout)
|
|
return int(m.group(1)) if m else 50
|
|
|
|
def set_brightness(v):
|
|
v = max(1, v)
|
|
run(['brightnessctl', 'set', f'{v}%', '-q'])
|
|
_wob(f'b:{v}')
|
|
|
|
def _wob(msg):
|
|
fifo = '/tmp/wob.fifo'
|
|
if os.path.exists(fifo):
|
|
try:
|
|
fd = os.open(fifo, os.O_WRONLY | os.O_NONBLOCK)
|
|
os.write(fd, f'{msg}\n'.encode())
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
|
|
def get_sinks():
|
|
"""Retourne [(sink_name, description, is_default)] — exclut SUSPENDED."""
|
|
env = {**os.environ, 'LANG': 'C', 'LC_ALL': 'C'}
|
|
r_default = run(['pactl', 'get-default-sink'], env=env)
|
|
default_name = r_default.stdout.strip()
|
|
|
|
r_full = run(['pactl', 'list', 'sinks'], env=env)
|
|
sinks, state, name, desc = [], None, None, None
|
|
for line in r_full.stdout.splitlines():
|
|
st = re.search(r'^\s+State:\s+(\S+)', line)
|
|
nm = re.search(r'^\s+Name:\s+(.+)$', line)
|
|
ds = re.search(r'^\s+Description:\s+(.+)$', line)
|
|
if st: state = st.group(1)
|
|
elif nm: name = nm.group(1).strip()
|
|
elif ds and name:
|
|
desc = ds.group(1).strip()
|
|
if state != 'SUSPENDED':
|
|
sinks.append((name, desc, name == default_name))
|
|
state, name, desc = None, None, None
|
|
return sinks
|
|
|
|
def set_default_sink(name):
|
|
run(['pactl', 'set-default-sink', name])
|
|
|
|
def get_sources():
|
|
"""Retourne [(source_name, description, is_default)] — exclut les .monitor."""
|
|
env = {**os.environ, 'LANG': 'C', 'LC_ALL': 'C'}
|
|
r_default = run(['pactl', 'get-default-source'], env=env)
|
|
default_name = r_default.stdout.strip()
|
|
|
|
r_full = run(['pactl', 'list', 'sources'], env=env)
|
|
sources, name, desc = [], None, None
|
|
for line in r_full.stdout.splitlines():
|
|
nm = re.search(r'^\s+Name:\s+(.+)$', line)
|
|
ds = re.search(r'^\s+Description:\s+(.+)$', line)
|
|
if nm:
|
|
name = nm.group(1).strip()
|
|
elif ds and name:
|
|
desc = ds.group(1).strip()
|
|
if '.monitor' not in name:
|
|
sources.append((name, desc, name == default_name))
|
|
name, desc = None, None
|
|
return sources
|
|
|
|
def set_default_source(name):
|
|
run(['pactl', 'set-default-source', name])
|
|
|
|
def source_icon(name, desc):
|
|
s = (name + desc).lower()
|
|
if 'bluetooth' in s or 'bluez' in s: return ''
|
|
if 'usb' in s: return ''
|
|
if 'headset' in s or 'headphone' in s: return ''
|
|
return ''
|
|
|
|
def sink_icon(name, desc):
|
|
s = (name + desc).lower()
|
|
if 'hdmi' in s or 'dp-' in s or 'displayport' in s: return ''
|
|
if 'bluetooth' in s or 'bluez' in s: return ''
|
|
if 'usb' in s: return ''
|
|
if 'headphone' in s or 'headset' in s: return ''
|
|
return ''
|
|
|
|
def short_desc(desc, maxlen=16):
|
|
return desc if len(desc) <= maxlen else desc[:maxlen - 1] + '…'
|
|
|
|
def vol_icon(muted):
|
|
return '' if muted else ''
|
|
|
|
def mic_icon(muted):
|
|
return '' if muted else ''
|
|
|
|
def bright_icon(pct):
|
|
if pct < 34: return ''
|
|
if pct < 67: return ''
|
|
return ''
|
|
|
|
# ── Popup ─────────────────────────────────────────────────────────────────────
|
|
|
|
class MediaPopup(Gtk.Window):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._blk = False
|
|
|
|
# ── Position — ancré à droite, toujours dans l'écran ─────────────────
|
|
GtkLayerShell.init_for_window(self)
|
|
GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY)
|
|
GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.TOP, True)
|
|
GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.RIGHT, True)
|
|
GtkLayerShell.set_margin(self, GtkLayerShell.Edge.TOP, 66)
|
|
GtkLayerShell.set_margin(self, GtkLayerShell.Edge.RIGHT, 12)
|
|
GtkLayerShell.set_keyboard_mode(self, GtkLayerShell.KeyboardMode.ON_DEMAND)
|
|
GtkLayerShell.set_exclusive_zone(self, -1)
|
|
self.set_decorated(False)
|
|
self.set_resizable(False)
|
|
self.set_default_size(POPUP_WIDTH, -1)
|
|
|
|
# ── CSS ───────────────────────────────────────────────────────────────
|
|
provider = Gtk.CssProvider()
|
|
provider.load_from_data(CSS.encode())
|
|
Gtk.StyleContext.add_provider_for_screen(
|
|
Gdk.Screen.get_default(), provider,
|
|
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
|
)
|
|
|
|
# ── États initiaux ────────────────────────────────────────────────────
|
|
sink_vol, sink_muted = get_sink_volume()
|
|
src_vol, src_muted = get_source_volume()
|
|
self._sink_muted = sink_muted
|
|
self._src_muted = src_muted
|
|
|
|
# ── Layout ────────────────────────────────────────────────────────────
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
box.set_name('container')
|
|
self.add(box)
|
|
|
|
# ╔═══ SORTIE ══════════════════════════════════════════════════════════╗
|
|
sinks = get_sinks()
|
|
box.pack_start(self._section_header('SORTIE', ''), False, False, 0)
|
|
self.sink_device_lbl = self._device_label(
|
|
get_node_name('@DEFAULT_AUDIO_SINK@'))
|
|
box.pack_start(self.sink_device_lbl, False, False, 2)
|
|
if len(sinks) > 1:
|
|
box.pack_start(self._sink_selector(sinks), False, False, 4)
|
|
sink_row, self.sink_scale, self.sink_pct, self.sink_icon = \
|
|
self._slider_row(sink_vol, sink_muted, 'audio', vol_icon(sink_muted),
|
|
self._toggle_sink_mute, '@DEFAULT_AUDIO_SINK@')
|
|
box.pack_start(sink_row, False, False, 4)
|
|
|
|
# ╔═══ ENTRÉE ══════════════════════════════════════════════════════════╗
|
|
sep1 = Gtk.Label(label='─' * 34)
|
|
sep1.set_name('separator')
|
|
box.pack_start(sep1, False, False, 0)
|
|
|
|
sources = get_sources()
|
|
box.pack_start(self._section_header('ENTRÉE', ''), False, False, 0)
|
|
self.src_device_lbl = self._device_label(
|
|
get_node_name('@DEFAULT_AUDIO_SOURCE@'))
|
|
box.pack_start(self.src_device_lbl, False, False, 2)
|
|
if len(sources) > 1:
|
|
box.pack_start(self._source_selector(sources), False, False, 4)
|
|
src_row, self.src_scale, self.src_pct, self.src_icon = \
|
|
self._slider_row(src_vol, src_muted, 'audio', mic_icon(src_muted),
|
|
self._toggle_src_mute, '@DEFAULT_AUDIO_SOURCE@')
|
|
box.pack_start(src_row, False, False, 4)
|
|
|
|
# ╔═══ LUMINOSITÉ ══════════════════════════════════════════════════════╗
|
|
sep2 = Gtk.Label(label='─' * 34)
|
|
sep2.set_name('separator')
|
|
box.pack_start(sep2, False, False, 0)
|
|
|
|
bright_row, self.bright_scale, self.bright_pct, self.bright_icon_lbl = \
|
|
self._bright_row(get_brightness())
|
|
box.pack_start(bright_row, False, False, 6)
|
|
|
|
# ── Refresh + fermeture ───────────────────────────────────────────────
|
|
GLib.timeout_add(2000, self._refresh)
|
|
self.connect('key-press-event', lambda w, e:
|
|
self.destroy() if e.keyval == Gdk.KEY_Escape else None)
|
|
self.connect('focus-out-event', lambda *_: self.destroy())
|
|
self.show_all()
|
|
# GTK3 bug : set_value() avant réalisation → highlight width=0 à max
|
|
# Forcer un re-calcul après que les widgets sont visibles
|
|
GLib.idle_add(self._redraw_scales)
|
|
self.grab_focus()
|
|
|
|
def _redraw_scales(self):
|
|
"""Force GTK3 à recalculer les highlights.
|
|
set_value(même_valeur) est un no-op — on oscille ±1 pour déclencher
|
|
un vrai recalcul de la position du highlight dans le trough."""
|
|
self._blk = True
|
|
for scale in [self.sink_scale, self.src_scale, self.bright_scale]:
|
|
v = scale.get_value()
|
|
adj = scale.get_adjustment()
|
|
lo = adj.get_lower()
|
|
scale.set_value(max(lo, v - 1)) # valeur différente → GTK recalcule
|
|
scale.set_value(v) # retour à la valeur réelle
|
|
self._blk = False
|
|
return False
|
|
|
|
# ── Sélecteur de sortie ────────────────────────────────────────────────────
|
|
|
|
def _sink_selector(self, sinks):
|
|
self._sink_btns = {} # name → button
|
|
self._sink_descs = {} # name → desc
|
|
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
|
for name, desc, is_default in sinks:
|
|
self._sink_descs[name] = desc
|
|
btn = Gtk.Button()
|
|
btn.set_name('device-btn')
|
|
btn.set_hexpand(True)
|
|
lbl = Gtk.Label(label=self._sink_label(name, desc, is_default))
|
|
lbl.set_halign(Gtk.Align.START)
|
|
btn.add(lbl)
|
|
if is_default:
|
|
btn.get_style_context().add_class('active')
|
|
btn.connect('clicked', self._on_sink_selected, name)
|
|
col.pack_start(btn, False, True, 0)
|
|
self._sink_btns[name] = btn
|
|
return col
|
|
|
|
def _sink_label(self, name, desc, active):
|
|
check = ' ' if active else ''
|
|
return f'{sink_icon(name, desc)} {desc}{check}'
|
|
|
|
def _source_selector(self, sources):
|
|
self._src_btns = {}
|
|
self._src_descs = {}
|
|
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
|
for name, desc, is_default in sources:
|
|
self._src_descs[name] = desc
|
|
btn = Gtk.Button()
|
|
btn.set_name('device-btn')
|
|
btn.set_hexpand(True)
|
|
lbl = Gtk.Label(label=self._src_label(name, desc, is_default))
|
|
lbl.set_halign(Gtk.Align.START)
|
|
btn.add(lbl)
|
|
if is_default:
|
|
btn.get_style_context().add_class('active')
|
|
btn.connect('clicked', self._on_source_selected, name)
|
|
col.pack_start(btn, False, True, 0)
|
|
self._src_btns[name] = btn
|
|
return col
|
|
|
|
def _src_label(self, name, desc, active):
|
|
check = ' ' if active else ''
|
|
return f'{source_icon(name, desc)} {desc}{check}'
|
|
|
|
def _on_source_selected(self, _btn, name):
|
|
for n, b in self._src_btns.items():
|
|
b.get_style_context().remove_class('active')
|
|
b.get_child().set_label(self._src_label(n, self._src_descs[n], False))
|
|
self._src_btns[name].get_style_context().add_class('active')
|
|
self._src_btns[name].get_child().set_label(
|
|
self._src_label(name, self._src_descs[name], True))
|
|
set_default_source(name)
|
|
self.src_device_lbl.set_label(self._src_descs[name])
|
|
vol, muted = get_source_volume()
|
|
self._blk = True
|
|
self.src_scale.set_value(vol)
|
|
self.src_pct.set_label(f'{vol}%')
|
|
self._blk = False
|
|
self._src_muted = muted
|
|
self._apply_mute(self.src_scale, self.src_icon, muted, mic_icon)
|
|
subprocess.Popen(['pkill', '-RTMIN+1', 'waybar'],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
def _on_sink_selected(self, _btn, name):
|
|
for n, b in self._sink_btns.items():
|
|
b.get_style_context().remove_class('active')
|
|
b.get_child().set_label(
|
|
self._sink_label(n, self._sink_descs[n], False))
|
|
self._sink_btns[name].get_style_context().add_class('active')
|
|
self._sink_btns[name].get_child().set_label(
|
|
self._sink_label(name, self._sink_descs[name], True))
|
|
set_default_sink(name)
|
|
self.sink_device_lbl.set_label(self._sink_descs[name])
|
|
vol, muted = get_sink_volume()
|
|
self._blk = True
|
|
self.sink_scale.set_value(vol)
|
|
self.sink_pct.set_label(f'{vol}%')
|
|
self._blk = False
|
|
self._sink_muted = muted
|
|
self._apply_mute(self.sink_scale, self.sink_icon, muted, vol_icon)
|
|
subprocess.Popen(['pkill', '-RTMIN+1', 'waybar'],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
# ── Builders UI ───────────────────────────────────────────────────────────
|
|
|
|
def _section_header(self, label, icon):
|
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
ico = Gtk.Label(label=icon)
|
|
ico.set_name('section-label')
|
|
row.pack_start(ico, False, False, 0)
|
|
lbl = Gtk.Label(label=label)
|
|
lbl.set_name('section-label')
|
|
lbl.set_halign(Gtk.Align.START)
|
|
row.pack_start(lbl, True, True, 0)
|
|
return row
|
|
|
|
def _device_label(self, name):
|
|
lbl = Gtk.Label(label=name)
|
|
lbl.set_name('device-name')
|
|
lbl.set_halign(Gtk.Align.START)
|
|
lbl.set_ellipsize(3)
|
|
lbl.set_margin_start(4)
|
|
return lbl
|
|
|
|
def _slider_row(self, val, muted, css_class, icon_char, mute_cb, target):
|
|
"""Retourne (row, scale, pct_label, icon_btn)"""
|
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
|
|
# Icône = bouton mute
|
|
icon_btn = Gtk.Button(label=icon_char)
|
|
icon_btn.set_name('mute-icon' if target == '@DEFAULT_AUDIO_SINK@' else 'mic-icon')
|
|
if muted:
|
|
icon_btn.get_style_context().add_class('muted')
|
|
icon_btn.connect('clicked', mute_cb)
|
|
row.pack_start(icon_btn, False, False, 0)
|
|
|
|
# Slider
|
|
scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 5)
|
|
scale.set_value(val)
|
|
scale.set_draw_value(False)
|
|
scale.set_hexpand(True)
|
|
scale.get_style_context().add_class(css_class)
|
|
if muted:
|
|
scale.get_style_context().add_class('muted')
|
|
scale.connect('value-changed', lambda s, t=target:
|
|
self._on_audio_changed(s, pct_lbl, t))
|
|
row.pack_start(scale, True, True, 0)
|
|
|
|
# %
|
|
pct_lbl = Gtk.Label(label=f'{val}%')
|
|
pct_lbl.set_name('pct-label')
|
|
pct_lbl.set_halign(Gtk.Align.END)
|
|
row.pack_end(pct_lbl, False, False, 0)
|
|
|
|
return row, scale, pct_lbl, icon_btn
|
|
|
|
def _bright_row(self, pct):
|
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
|
|
icon_lbl = Gtk.Label(label=bright_icon(pct))
|
|
icon_lbl.set_name('bright-icon')
|
|
row.pack_start(icon_lbl, False, False, 0)
|
|
|
|
scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 1, 100, 5)
|
|
scale.set_value(pct)
|
|
scale.set_draw_value(False)
|
|
scale.set_hexpand(True)
|
|
scale.get_style_context().add_class('bright')
|
|
scale.connect('value-changed', self._on_bright_changed)
|
|
row.pack_start(scale, True, True, 0)
|
|
|
|
pct_lbl = Gtk.Label(label=f'{pct}%')
|
|
pct_lbl.set_name('pct-label')
|
|
pct_lbl.set_halign(Gtk.Align.END)
|
|
row.pack_end(pct_lbl, False, False, 0)
|
|
|
|
return row, scale, pct_lbl, icon_lbl
|
|
|
|
# ── Handlers ──────────────────────────────────────────────────────────────
|
|
|
|
def _on_audio_changed(self, scale, pct_lbl, target):
|
|
if self._blk:
|
|
return
|
|
v = int(scale.get_value())
|
|
pct_lbl.set_label(f'{v}%')
|
|
if target == '@DEFAULT_AUDIO_SINK@':
|
|
set_sink_vol(v)
|
|
else:
|
|
set_source_vol(v)
|
|
|
|
def _on_bright_changed(self, scale):
|
|
if self._blk:
|
|
return
|
|
v = int(scale.get_value())
|
|
self.bright_pct.set_label(f'{v}%')
|
|
self.bright_icon_lbl.set_label(bright_icon(v))
|
|
set_brightness(v)
|
|
|
|
def _apply_mute(self, scale, icon_btn, muted, icon_fn):
|
|
sc = scale.get_style_context()
|
|
bc = icon_btn.get_style_context()
|
|
if muted:
|
|
sc.add_class('muted')
|
|
bc.add_class('muted')
|
|
else:
|
|
sc.remove_class('muted')
|
|
bc.remove_class('muted')
|
|
icon_btn.set_label(icon_fn(muted))
|
|
|
|
def _toggle_sink_mute(self, _btn):
|
|
toggle_sink_mute()
|
|
_, self._sink_muted = get_sink_volume()
|
|
self._apply_mute(self.sink_scale, self.sink_icon,
|
|
self._sink_muted, vol_icon)
|
|
|
|
def _toggle_src_mute(self, _btn):
|
|
toggle_source_mute()
|
|
_, self._src_muted = get_source_volume()
|
|
self._apply_mute(self.src_scale, self.src_icon,
|
|
self._src_muted, mic_icon)
|
|
|
|
def _refresh(self):
|
|
sink_vol, sink_muted = get_sink_volume()
|
|
src_vol, src_muted = get_source_volume()
|
|
|
|
if sink_muted != self._sink_muted:
|
|
self._sink_muted = sink_muted
|
|
self._apply_mute(self.sink_scale, self.sink_icon,
|
|
sink_muted, vol_icon)
|
|
if src_muted != self._src_muted:
|
|
self._src_muted = src_muted
|
|
self._apply_mute(self.src_scale, self.src_icon,
|
|
src_muted, mic_icon)
|
|
|
|
self._blk = True
|
|
self.sink_scale.set_value(sink_vol)
|
|
self.sink_pct.set_label(f'{sink_vol}%')
|
|
self.src_scale.set_value(src_vol)
|
|
self.src_pct.set_label(f'{src_vol}%')
|
|
self._blk = False
|
|
|
|
return True
|
|
|
|
|
|
if __name__ == '__main__':
|
|
win = MediaPopup()
|
|
win.connect('destroy', Gtk.main_quit)
|
|
Gtk.main()
|