Files
dotfiles-violet-chaton/INSTALL/configs/waybar/scripts/vc-media-popup.py
Tetardtek ea0424239c fix(popup): corriger sélecteur GTK3 pour la barre des sliders audio
GTK3 ne matche pas 'scale.audio highlight' en pratique, il faut
le chemin complet 'scale.audio trough highlight' pour que la barre
colorée (rose/cyan) soit rendue dans les sliders volume et micro.
2026-02-23 16:02:29 +01:00

481 lines
16 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);
}
/* ── 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, **kw):
return subprocess.run(cmd, capture_output=True, text=True, timeout=2, **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}%'])
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 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 ─────────────────────────────────────────────────────────
display = Gdk.Display.get_default()
monitor = display.get_primary_monitor() if display else None
screen_w = monitor.get_geometry().width if monitor else 1920
module_center = screen_w - 16 - 210
margin_left = max(0, module_center - POPUP_WIDTH // 2)
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.LEFT, True)
GtkLayerShell.set_margin(self, GtkLayerShell.Edge.TOP, 66)
GtkLayerShell.set_margin(self, GtkLayerShell.Edge.LEFT, margin_left)
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 ══════════════════════════════════════════════════════════╗
box.pack_start(self._section_header('SORTIE', '󰕾'), False, False, 0)
box.pack_start(self._device_label(
get_node_name('@DEFAULT_AUDIO_SINK@')), False, False, 2)
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)
box.pack_start(self._section_header('ENTRÉE', '󰍬'), False, False, 0)
box.pack_start(self._device_label(
get_node_name('@DEFAULT_AUDIO_SOURCE@')), False, False, 2)
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()
self.grab_focus()
# ── 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()