Files
dotfiles-violet-chaton/INSTALL/configs/waybar/scripts/vc-media-popup.py
Tetardtek f74d5bc173 fix(wob+popup): contours colorés + labels rose + slider micro visible
wob-overlay.py :
- Bordure rose (#ff79c6) pour volume, cyan (#8be9fd) pour luminosité
  via classes CSS dynamiques sur #wob-box
- Ouverture FIFO en O_RDWR : plus de deadlock au redémarrage,
  plus d'EOF entre deux écritures (supprime la dépendance à exec 3<>)

vc-media-popup.py :
- Labels ENTRÉE / SORTIE en rose (#ff79c6) au lieu de 45% opacité
- min-height: 22px sur scale.audio et scale.bright pour forcer
  le rendu du slider micro (était compressé à 0 par GTK3)
2026-02-23 14:33:18 +01:00

479 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 highlight {
background-color: #ff79c6;
border-radius: 3px;
border: none;
}
scale.audio.muted 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 highlight {
background-color: #8be9fd;
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()