diff --git a/INSTALL/configs/waybar/scripts/vc-brightness-popup.py b/INSTALL/configs/waybar/scripts/vc-brightness-popup.py
deleted file mode 100755
index 8b16dd2..0000000
--- a/INSTALL/configs/waybar/scripts/vc-brightness-popup.py
+++ /dev/null
@@ -1,245 +0,0 @@
-#!/usr/bin/env python3
-# vc-brightness-popup.py — Popup luminosité violet-chaton
-# Lancé par le clic sur le module backlight de waybar
-
-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 = b"""
-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;
-}
-
-#bright-icon {
- color: #8be9fd;
- font-family: "JetBrainsMono Nerd Font";
- font-size: 18px;
- min-width: 24px;
-}
-
-#bright-title {
- color: rgba(248, 248, 242, 0.55);
- font-family: "JetBrainsMono Nerd Font";
- font-size: 11px;
-}
-
-#device-name {
- color: #8be9fd;
- font-family: "JetBrainsMono Nerd Font";
- font-size: 11px;
- font-weight: bold;
-}
-
-#bright-pct {
- color: #f8f8f2;
- font-family: "JetBrainsMono Nerd Font";
- font-size: 13px;
- font-weight: bold;
- min-width: 44px;
-}
-
-#separator {
- color: rgba(92, 73, 108, 0.60);
- margin: 4px 0;
-}
-
-scale trough {
- background-color: rgba(92, 73, 108, 0.55);
- border-radius: 3px;
- min-height: 6px;
- border: none;
-}
-
-scale highlight {
- background-color: #8be9fd;
- border-radius: 3px;
- border: none;
-}
-
-scale slider {
- background-color: #f8f8f2;
- border-radius: 50%;
- min-width: 18px;
- min-height: 18px;
- border: 2px solid rgba(139, 233, 253, 0.80);
- box-shadow: none;
- transition: none;
-}
-
-scale slider:hover {
- background-color: #8be9fd;
- border-color: #8be9fd;
-}
-"""
-
-POPUP_WIDTH = 300
-
-# ── Brightness helpers ────────────────────────────────────────────────────────
-
-def get_brightness():
- """Retourne (valeur 0-100, nom du device)."""
- try:
- r = subprocess.run(
- ['brightnessctl', 'info'],
- capture_output=True, text=True, timeout=2
- )
- pct_match = re.search(r'\((\d+)%\)', r.stdout)
- dev_match = re.search(r"Device '([^']+)'", r.stdout)
- pct = int(pct_match.group(1)) if pct_match else 50
- dev = dev_match.group(1) if dev_match else 'Écran'
- # Rendre le nom plus lisible
- dev = dev.replace('_', ' ').replace('backlight', '').strip().title()
- return pct, dev
- except Exception:
- return 50, 'Écran'
-
-def set_brightness(pct):
- pct = max(1, min(100, pct)) # minimum 1% pour ne pas éteindre l'écran
- subprocess.run(
- ['brightnessctl', 'set', f'{pct}%', '-q'],
- capture_output=True
- )
- # Feedback wob
- fifo = '/tmp/wob.fifo'
- if os.path.exists(fifo):
- try:
- fd = os.open(fifo, os.O_WRONLY | os.O_NONBLOCK)
- os.write(fd, f'{pct}\n'.encode())
- os.close(fd)
- except OSError:
- pass
-
-def bright_icon(pct):
- if pct < 34:
- return ''
- if pct < 67:
- return ''
- return ''
-
-# ── Popup ─────────────────────────────────────────────────────────────────────
-
-class BrightnessPopup(Gtk.Window):
- def __init__(self):
- super().__init__()
- self._blocked = False
-
- # ── Position : centré sous le module backlight ────────────────────────
- display = Gdk.Display.get_default()
- monitor = display.get_primary_monitor() if display else None
- screen_w = monitor.get_geometry().width if monitor else 1920
-
- # Backlight est le 2e module de la pill droite (~250px depuis le bord)
- module_center = screen_w - 16 - 250
- 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)
- Gtk.StyleContext.add_provider_for_screen(
- Gdk.Screen.get_default(),
- provider,
- Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
- )
-
- # ── État initial ──────────────────────────────────────────────────────
- pct, dev = get_brightness()
-
- # ── Layout ────────────────────────────────────────────────────────────
- container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
- container.set_name('container')
- self.add(container)
-
- # Ligne device
- dev_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
- dev_icon = Gtk.Label(label='')
- dev_icon.set_name('bright-title')
- dev_row.pack_start(dev_icon, False, False, 0)
- dev_label = Gtk.Label(label=dev)
- dev_label.set_name('device-name')
- dev_label.set_halign(Gtk.Align.START)
- dev_label.set_ellipsize(3)
- dev_row.pack_start(dev_label, True, True, 0)
- container.pack_start(dev_row, False, False, 0)
-
- # Séparateur
- sep = Gtk.Label(label='─' * 30)
- sep.set_name('separator')
- container.pack_start(sep, False, False, 4)
-
- # En-tête : icône + "Luminosité" + %
- header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
- self.icon = Gtk.Label(label=bright_icon(pct))
- self.icon.set_name('bright-icon')
- header.pack_start(self.icon, False, False, 0)
-
- title = Gtk.Label(label='Luminosité')
- title.set_name('bright-title')
- title.set_halign(Gtk.Align.START)
- header.pack_start(title, True, True, 0)
-
- self.pct = Gtk.Label(label=f'{pct}%')
- self.pct.set_name('bright-pct')
- self.pct.set_halign(Gtk.Align.END)
- header.pack_end(self.pct, False, False, 0)
- container.pack_start(header, False, False, 0)
-
- # Slider (min 1% pour ne pas éteindre l'écran)
- self.scale = Gtk.Scale.new_with_range(
- Gtk.Orientation.HORIZONTAL, 1, 100, 5
- )
- self.scale.set_value(pct)
- self.scale.set_draw_value(False)
- self.scale.set_hexpand(True)
- self.scale.connect('value-changed', self._on_changed)
- container.pack_start(self.scale, False, False, 0)
-
- # ── Fermeture ─────────────────────────────────────────────────────────
- self.connect('key-press-event', self._on_key)
- self.connect('focus-out-event', lambda *_: self.destroy())
-
- self.show_all()
- self.grab_focus()
-
- def _on_changed(self, scale):
- if self._blocked:
- return
- pct = int(scale.get_value())
- self.pct.set_label(f'{pct}%')
- self.icon.set_label(bright_icon(pct))
- set_brightness(pct)
-
- def _on_key(self, _widget, event):
- if event.keyval == Gdk.KEY_Escape:
- self.destroy()
-
-
-if __name__ == '__main__':
- win = BrightnessPopup()
- win.connect('destroy', Gtk.main_quit)
- Gtk.main()
diff --git a/INSTALL/configs/waybar/scripts/vc-media-popup.py b/INSTALL/configs/waybar/scripts/vc-media-popup.py
index 6ff9e28..f720754 100755
--- a/INSTALL/configs/waybar/scripts/vc-media-popup.py
+++ b/INSTALL/configs/waybar/scripts/vc-media-popup.py
@@ -3,9 +3,16 @@
# Lancé depuis le clic sur wireplumber OU backlight
import gi
+import math
+import threading
+import urllib.request
+
gi.require_version('Gtk', '3.0')
gi.require_version('GtkLayerShell', '0.1')
-from gi.repository import Gtk, Gdk, GtkLayerShell, GLib
+gi.require_version('GdkPixbuf', '2.0')
+gi.require_version('Pango', '1.0')
+gi.require_version('PangoCairo', '1.0')
+from gi.repository import Gtk, Gdk, GtkLayerShell, GLib, GdkPixbuf, Pango, PangoCairo
import subprocess
import os
import re
@@ -201,6 +208,46 @@ scale.bright slider:hover {
font-size: 17px;
min-width: 28px;
}
+
+/* ── Section MPRIS ────────────────────────────────────────────────────────── */
+
+#mpris-title {
+ color: #f8f8f2;
+ font-family: "JetBrainsMono Nerd Font";
+ font-size: 12px;
+ font-weight: bold;
+}
+
+#mpris-artist {
+ color: rgba(248, 248, 242, 0.55);
+ font-family: "JetBrainsMono Nerd Font";
+ font-size: 11px;
+}
+
+#mpris-btn {
+ background-color: transparent;
+ color: #e79cfe;
+ font-family: "JetBrainsMono Nerd Font";
+ font-size: 16px;
+ border: none;
+ border-radius: 6px;
+ padding: 4px 8px;
+ min-width: 0;
+}
+
+#mpris-btn:hover {
+ background-color: rgba(231, 156, 254, 0.15);
+}
+
+#mpris-btn.play {
+ font-size: 20px;
+ color: #ff79c6;
+ padding: 4px 12px;
+}
+
+#mpris-btn.play:hover {
+ background-color: rgba(255, 121, 198, 0.15);
+}
"""
POPUP_WIDTH = 310
@@ -312,6 +359,28 @@ def get_sources():
def set_default_source(name):
run(['pactl', 'set-default-source', name])
+def get_mpris_info():
+ """Retourne dict ou None si pas de lecteur actif."""
+ try:
+ r = run(['playerctl', 'metadata', '--format',
+ '{{title}}||{{artist}}||{{mpris:artUrl}}||{{status}}'])
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return None
+ if r.returncode != 0 or not r.stdout.strip():
+ return None
+ parts = r.stdout.strip().split('||', 3)
+ if len(parts) < 4:
+ return None
+ title, artist, art_url, status = [p.strip() for p in parts]
+ if not title:
+ return None
+ return {
+ 'title': title,
+ 'artist': artist,
+ 'art_url': art_url,
+ 'status': status.lower(), # 'playing' | 'paused' | 'stopped'
+ }
+
def source_icon(name, desc):
s = (name + desc).lower()
if 'bluetooth' in s or 'bluez' in s: return ''
@@ -341,12 +410,101 @@ def bright_icon(pct):
if pct < 67: return ''
return ''
+# ── Art widget (album art / miniature YouTube) ────────────────────────────────
+
+class ArtWidget(Gtk.DrawingArea):
+ SIZE = 72
+
+ def __init__(self):
+ super().__init__()
+ self._pixbuf = None
+ self._url = None
+ self.set_size_request(self.SIZE, self.SIZE)
+ self.connect('draw', self._on_draw)
+
+ def load_url(self, url):
+ if url == self._url:
+ return
+ self._url = url
+ self._pixbuf = None
+ self.queue_draw()
+ if not url:
+ return
+ threading.Thread(target=self._fetch, args=(url,), daemon=True).start()
+
+ def _fetch(self, url):
+ try:
+ if url.startswith('file://'):
+ raw = GdkPixbuf.Pixbuf.new_from_file(url[7:])
+ else:
+ req = urllib.request.Request(
+ url, headers={'User-Agent': 'Mozilla/5.0'})
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ data = resp.read()
+ loader = GdkPixbuf.PixbufLoader()
+ loader.write(data)
+ loader.close()
+ raw = loader.get_pixbuf()
+
+ if raw:
+ s = self.SIZE
+ ow, oh = raw.get_width(), raw.get_height()
+ scale = min(s / ow, s / oh)
+ nw = max(1, int(ow * scale))
+ nh = max(1, int(oh * scale))
+ pixbuf = raw.scale_simple(nw, nh, GdkPixbuf.InterpType.BILINEAR)
+ else:
+ pixbuf = None
+ except Exception:
+ pixbuf = None
+ GLib.idle_add(self._set_pixbuf, pixbuf, url)
+
+ def _set_pixbuf(self, pixbuf, url):
+ if url == self._url:
+ self._pixbuf = pixbuf
+ self.queue_draw()
+ return False
+
+ def _on_draw(self, _widget, cr):
+ s, r = self.SIZE, 10
+
+ # Coins arrondis (clip)
+ cr.new_sub_path()
+ cr.arc(r, r, r, math.pi, 3 * math.pi / 2)
+ cr.arc(s - r, r, r, -math.pi / 2, 0)
+ cr.arc(s - r, s - r, r, 0, math.pi / 2)
+ cr.arc(r, s - r, r, math.pi / 2, math.pi)
+ cr.close_path()
+ cr.clip()
+
+ # Fond violet
+ cr.set_source_rgba(73/255, 49/255, 97/255, 0.85)
+ cr.paint()
+
+ if self._pixbuf:
+ pw = self._pixbuf.get_width()
+ ph = self._pixbuf.get_height()
+ Gdk.cairo_set_source_pixbuf(cr, self._pixbuf,
+ (s - pw) / 2, (s - ph) / 2)
+ cr.paint()
+ else:
+ # Icône note de musique (placeholder)
+ layout = PangoCairo.create_layout(cr)
+ layout.set_markup('')
+ lw, lh = layout.get_size()
+ cr.set_source_rgba(231/255, 156/255, 254/255, 0.45)
+ cr.move_to((s - lw / Pango.SCALE) / 2,
+ (s - lh / Pango.SCALE) / 2)
+ PangoCairo.show_layout(cr, layout)
+
+
# ── Popup ─────────────────────────────────────────────────────────────────────
class MediaPopup(Gtk.Window):
def __init__(self):
super().__init__()
- self._blk = False
+ self._blk = False
+ self._has_mpris = False
# ── Position — ancré à droite, toujours dans l'écran ─────────────────
GtkLayerShell.init_for_window(self)
@@ -380,6 +538,14 @@ class MediaPopup(Gtk.Window):
box.set_name('container')
self.add(box)
+ # ╔═══ LECTURE (MPRIS) — affiché seulement si lecteur actif ════════════╗
+ mpris_info = get_mpris_info()
+ if mpris_info:
+ self._build_mpris_section(box, mpris_info)
+ sep0 = Gtk.Label(label='─' * 34)
+ sep0.set_name('separator')
+ box.pack_start(sep0, False, False, 0)
+
# ╔═══ SORTIE ══════════════════════════════════════════════════════════╗
sinks = get_sinks()
box.pack_start(self._section_header('SORTIE', ''), False, False, 0)
@@ -426,29 +592,86 @@ class MediaPopup(Gtk.Window):
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
+ # ── MPRIS section builder ─────────────────────────────────────────────────
+
+ def _build_mpris_section(self, box, info):
+ box.pack_start(self._section_header('LECTURE', ''), False, False, 0)
+
+ content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
+ content.set_margin_top(4)
+ content.set_margin_bottom(4)
+
+ # Album art
+ self.mpris_art = ArtWidget()
+ content.pack_start(self.mpris_art, False, False, 0)
+
+ # Infos + contrôles
+ info_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ info_col.set_valign(Gtk.Align.CENTER)
+
+ self.mpris_title = Gtk.Label(label=info['title'])
+ self.mpris_title.set_name('mpris-title')
+ self.mpris_title.set_halign(Gtk.Align.START)
+ self.mpris_title.set_ellipsize(3)
+ self.mpris_title.set_max_width_chars(20)
+ info_col.pack_start(self.mpris_title, False, False, 0)
+
+ self.mpris_artist = Gtk.Label(label=info['artist'] or '—')
+ self.mpris_artist.set_name('mpris-artist')
+ self.mpris_artist.set_halign(Gtk.Align.START)
+ self.mpris_artist.set_ellipsize(3)
+ self.mpris_artist.set_max_width_chars(20)
+ info_col.pack_start(self.mpris_artist, False, False, 0)
+
+ # Contrôles prev / play-pause / next
+ ctrl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ ctrl.set_margin_top(4)
+
+ btn_prev = Gtk.Button(label='')
+ btn_prev.set_name('mpris-btn')
+ btn_prev.connect('clicked', lambda _: run(['playerctl', 'previous']))
+
+ self.btn_play = Gtk.Button(
+ label='' if info['status'] == 'playing' else '')
+ self.btn_play.set_name('mpris-btn')
+ self.btn_play.get_style_context().add_class('play')
+ self.btn_play.connect('clicked', self._on_play_pause)
+
+ btn_next = Gtk.Button(label='')
+ btn_next.set_name('mpris-btn')
+ btn_next.connect('clicked', lambda _: run(['playerctl', 'next']))
+
+ ctrl.pack_start(btn_prev, False, False, 0)
+ ctrl.pack_start(self.btn_play, False, False, 0)
+ ctrl.pack_start(btn_next, False, False, 0)
+ info_col.pack_start(ctrl, False, False, 0)
+
+ content.pack_start(info_col, True, True, 0)
+ box.pack_start(content, False, False, 4)
+
+ # Charger l'artwork en arrière-plan
+ if info.get('art_url'):
+ self.mpris_art.load_url(info['art_url'])
+
+ self._has_mpris = True
+ self._mpris_status = info['status']
+
+ def _on_play_pause(self, _btn):
+ run(['playerctl', 'play-pause'])
+ info = get_mpris_info()
+ if info and self._has_mpris:
+ self._mpris_status = info['status']
+ self.btn_play.set_label(
+ '' if info['status'] == 'playing' else '')
# ── Sélecteur de sortie ────────────────────────────────────────────────────
def _sink_selector(self, sinks):
- self._sink_btns = {} # name → button
- self._sink_descs = {} # name → desc
+ self._sink_btns = {}
+ self._sink_descs = {}
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
for name, desc, is_default in sinks:
self._sink_descs[name] = desc
@@ -556,7 +779,6 @@ class MediaPopup(Gtk.Window):
"""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:
@@ -564,7 +786,6 @@ class MediaPopup(Gtk.Window):
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)
@@ -576,7 +797,6 @@ class MediaPopup(Gtk.Window):
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)
@@ -649,7 +869,20 @@ class MediaPopup(Gtk.Window):
self._apply_mute(self.src_scale, self.src_icon,
self._src_muted, mic_icon)
+ def _redraw_scales(self):
+ """Force GTK3 à recalculer les highlights."""
+ 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))
+ scale.set_value(v)
+ self._blk = False
+ return False
+
def _refresh(self):
+ # ── Audio ──────────────────────────────────────────────────────────────
sink_vol, sink_muted = get_sink_volume()
src_vol, src_muted = get_source_volume()
@@ -669,6 +902,20 @@ class MediaPopup(Gtk.Window):
self.src_pct.set_label(f'{src_vol}%')
self._blk = False
+ # ── MPRIS ──────────────────────────────────────────────────────────────
+ if self._has_mpris:
+ info = get_mpris_info()
+ if info:
+ self.mpris_title.set_label(info['title'])
+ self.mpris_artist.set_label(info['artist'] or '—')
+ if info['status'] != self._mpris_status:
+ self._mpris_status = info['status']
+ self.btn_play.set_label(
+ '' if info['status'] == 'playing' else '')
+ cur_url = info.get('art_url', '')
+ if cur_url != self.mpris_art._url:
+ self.mpris_art.load_url(cur_url)
+
return True
diff --git a/INSTALL/configs/waybar/scripts/vc-volume-popup.py b/INSTALL/configs/waybar/scripts/vc-volume-popup.py
deleted file mode 100755
index a51a3ed..0000000
--- a/INSTALL/configs/waybar/scripts/vc-volume-popup.py
+++ /dev/null
@@ -1,422 +0,0 @@
-#!/usr/bin/env python3
-# vc-volume-popup.py — Popup volume slider violet-chaton
-# Lancé par le clic sur le module wireplumber de waybar
-
-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 = b"""
-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;
-}
-
-#vol-icon {
- color: #ff79c6;
- font-family: "JetBrainsMono Nerd Font";
- font-size: 18px;
- min-width: 24px;
-}
-
-#vol-title {
- color: rgba(248, 248, 242, 0.55);
- font-family: "JetBrainsMono Nerd Font";
- font-size: 11px;
-}
-
-#sink-name {
- color: #8be9fd;
- font-family: "JetBrainsMono Nerd Font";
- font-size: 11px;
- font-weight: bold;
-}
-
-#vol-pct {
- color: #f8f8f2;
- font-family: "JetBrainsMono Nerd Font";
- font-size: 13px;
- font-weight: bold;
- min-width: 44px;
-}
-
-#separator {
- color: rgba(92, 73, 108, 0.60);
- margin: 4px 0;
-}
-
-scale trough {
- background-color: rgba(92, 73, 108, 0.55);
- border-radius: 3px;
- min-height: 6px;
- border: none;
-}
-
-scale highlight {
- background-color: #ff79c6;
- border-radius: 3px;
- border: none;
-}
-
-scale slider {
- background-color: #f8f8f2;
- border-radius: 50%;
- min-width: 18px;
- min-height: 18px;
- border: 2px solid rgba(255, 121, 198, 0.80);
- box-shadow: none;
- transition: none;
-}
-
-scale slider:hover {
- background-color: #e79cfe;
- border-color: #ff79c6;
-}
-
-#mute-btn {
- background: rgba(73, 49, 97, 0.50);
- border: 1px solid rgba(92, 73, 108, 0.60);
- border-radius: 8px;
- color: rgba(248, 248, 242, 0.65);
- font-family: "JetBrainsMono Nerd Font";
- font-size: 12px;
- padding: 5px 16px;
- margin-top: 6px;
-}
-
-#mute-btn:hover {
- background: rgba(255, 121, 198, 0.18);
- border-color: rgba(255, 121, 198, 0.45);
- color: #ff79c6;
-}
-
-#mute-btn.muted {
- color: #f38ba8;
- border-color: rgba(243, 139, 168, 0.45);
- background: rgba(243, 139, 168, 0.10);
-}
-
-#mic-btn {
- background: rgba(73, 49, 97, 0.50);
- border: 1px solid rgba(139, 233, 253, 0.35);
- border-radius: 8px;
- color: #8be9fd;
- font-family: "JetBrainsMono Nerd Font";
- font-size: 12px;
- padding: 5px 16px;
- margin-top: 4px;
-}
-
-#mic-btn:hover {
- background: rgba(139, 233, 253, 0.12);
- border-color: rgba(139, 233, 253, 0.60);
- color: #8be9fd;
-}
-
-#mic-btn.muted {
- color: #f38ba8;
- border-color: rgba(243, 139, 168, 0.45);
- background: rgba(243, 139, 168, 0.10);
-}
-"""
-
-POPUP_WIDTH = 300
-
-# ── Audio helpers ─────────────────────────────────────────────────────────────
-
-def get_volume():
- """Retourne (volume 0-100, is_muted)"""
- try:
- r = subprocess.run(
- ['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'],
- capture_output=True, text=True, timeout=2
- )
- parts = r.stdout.strip().split()
- vol = int(float(parts[1]) * 100)
- muted = '[MUTED]' in r.stdout
- return min(max(vol, 0), 100), muted
- except Exception:
- return 50, False
-
-def get_sink_name():
- """Retourne le nom humain de la sortie audio active."""
- try:
- r = subprocess.run(
- ['wpctl', 'inspect', '@DEFAULT_AUDIO_SINK@'],
- capture_output=True, text=True, timeout=2
- )
- # Chercher node.description en priorité, sinon node.nick
- for field in ('node.description', 'node.nick'):
- m = re.search(rf'{field}\s*=\s*"([^"]+)"', r.stdout)
- if m:
- return m.group(1)
- except Exception:
- pass
- return 'Sortie audio'
-
-def set_volume(vol):
- subprocess.run(
- ['wpctl', 'set-volume', '-l', '1.0', '@DEFAULT_AUDIO_SINK@', f'{vol}%'],
- capture_output=True
- )
- # Feedback wob (non-bloquant)
- fifo = '/tmp/wob.fifo'
- if os.path.exists(fifo):
- try:
- fd = os.open(fifo, os.O_WRONLY | os.O_NONBLOCK)
- os.write(fd, f'{vol}\n'.encode())
- os.close(fd)
- except OSError:
- pass
-
-def toggle_mute():
- subprocess.run(
- ['wpctl', 'set-mute', '@DEFAULT_AUDIO_SINK@', 'toggle'],
- capture_output=True
- )
-
-def get_mic_muted():
- """Retourne True si le micro actif est muté."""
- try:
- r = subprocess.run(
- ['wpctl', 'get-volume', '@DEFAULT_AUDIO_SOURCE@'],
- capture_output=True, text=True, timeout=2
- )
- return '[MUTED]' in r.stdout
- except Exception:
- return False
-
-def toggle_mic_mute():
- subprocess.run(
- ['wpctl', 'set-mute', '@DEFAULT_AUDIO_SOURCE@', 'toggle'],
- capture_output=True
- )
-
-def vol_icon(vol, muted):
- if muted or vol == 0:
- return ''
- if vol < 50:
- return ''
- return ''
-
-# ── Popup ─────────────────────────────────────────────────────────────────────
-
-class VolumePopup(Gtk.Window):
- def __init__(self):
- super().__init__()
- self._blocked = False
-
- # ── Position : centré sous le module wireplumber ──────────────────────
- # Wireplumber = 1er module de la pill droite (côté droit de l'écran).
- # On centre le popup horizontalement sous ce module.
- display = Gdk.Display.get_default()
- monitor = display.get_primary_monitor() if display else None
- if monitor:
- screen_w = monitor.get_geometry().width
- else:
- screen_w = 1920 # fallback
-
- # La pill droite a ~16px de marge depuis le bord droit.
- # Le module wireplumber est le 1er élément : ~180px depuis le bord droit.
- module_center = screen_w - 16 - 180
- 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)
- Gtk.StyleContext.add_provider_for_screen(
- Gdk.Screen.get_default(),
- provider,
- Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
- )
-
- # ── État initial ──────────────────────────────────────────────────────
- vol, muted = get_volume()
- self._muted = muted
- self._mic_muted = get_mic_muted()
- sink = get_sink_name()
-
- # ── Layout ────────────────────────────────────────────────────────────
- container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
- container.set_name('container')
- self.add(container)
-
- # Ligne sink (sortie active)
- sink_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
- sink_icon = Gtk.Label(label='')
- sink_icon.set_name('vol-title')
- sink_row.pack_start(sink_icon, False, False, 0)
- self.sink_label = Gtk.Label(label=sink)
- self.sink_label.set_name('sink-name')
- self.sink_label.set_halign(Gtk.Align.START)
- self.sink_label.set_ellipsize(3) # PANGO_ELLIPSIZE_END
- sink_row.pack_start(self.sink_label, True, True, 0)
- container.pack_start(sink_row, False, False, 0)
-
- # Séparateur
- sep = Gtk.Label(label='─' * 30)
- sep.set_name('separator')
- container.pack_start(sep, False, False, 4)
-
- # En-tête volume : icône + "Volume" + %
- header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
- self.icon = Gtk.Label(label=vol_icon(vol, muted))
- self.icon.set_name('vol-icon')
- header.pack_start(self.icon, False, False, 0)
-
- title = Gtk.Label(label='Volume')
- title.set_name('vol-title')
- title.set_halign(Gtk.Align.START)
- header.pack_start(title, True, True, 0)
-
- self.pct = Gtk.Label(label=f'{vol}%')
- self.pct.set_name('vol-pct')
- self.pct.set_halign(Gtk.Align.END)
- header.pack_end(self.pct, False, False, 0)
- container.pack_start(header, False, False, 0)
-
- # Slider
- self.scale = Gtk.Scale.new_with_range(
- Gtk.Orientation.HORIZONTAL, 0, 100, 5
- )
- self.scale.set_value(vol)
- self.scale.set_draw_value(False)
- self.scale.set_hexpand(True)
- self.scale.connect('value-changed', self._on_changed)
- container.pack_start(self.scale, False, False, 0)
-
- # Bouton mute
- self.mute_btn = Gtk.Button(label=f' {"Remettre le son" if muted else "Muet"}')
- self.mute_btn.set_name('mute-btn')
- self.mute_btn.set_halign(Gtk.Align.CENTER)
- if muted:
- self.mute_btn.get_style_context().add_class('muted')
- self.mute_btn.connect('clicked', self._on_mute)
- container.pack_start(self.mute_btn, False, False, 0)
-
- # ── Section micro ─────────────────────────────────────────────────────
- sep2 = Gtk.Label(label='─' * 30)
- sep2.set_name('separator')
- container.pack_start(sep2, False, False, 4)
-
- mic_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
- mic_icon = Gtk.Label(label='')
- mic_icon.set_name('vol-title')
- mic_icon.set_markup('')
- mic_row.pack_start(mic_icon, False, False, 0)
-
- mic_title = Gtk.Label(label='Micro')
- mic_title.set_name('vol-title')
- mic_title.set_halign(Gtk.Align.START)
- mic_row.pack_start(mic_title, True, True, 0)
-
- mic_label = ' Coupé' if self._mic_muted else ' Actif'
- self.mic_btn = Gtk.Button(label=mic_label)
- self.mic_btn.set_name('mic-btn')
- if self._mic_muted:
- self.mic_btn.get_style_context().add_class('muted')
- self.mic_btn.connect('clicked', self._on_mic_mute)
- mic_row.pack_end(self.mic_btn, False, False, 0)
-
- container.pack_start(mic_row, False, False, 0)
-
- # ── Refresh périodique (détecte changement de sink/micro) ─────────────
- GLib.timeout_add(2000, self._refresh_sink)
-
- # ── Fermeture ─────────────────────────────────────────────────────────
- self.connect('key-press-event', self._on_key)
- self.connect('focus-out-event', lambda *_: self.destroy())
-
- self.show_all()
- self.grab_focus()
-
- def _on_changed(self, scale):
- if self._blocked:
- return
- vol = int(scale.get_value())
- self.pct.set_label(f'{vol}%')
- self.icon.set_label(vol_icon(vol, self._muted))
- set_volume(vol)
-
- def _on_mute(self, btn):
- toggle_mute()
- _, self._muted = get_volume()
- vol = int(self.scale.get_value())
- self.icon.set_label(vol_icon(vol, self._muted))
- if self._muted:
- btn.get_style_context().add_class('muted')
- btn.set_label(' Remettre le son')
- else:
- btn.get_style_context().remove_class('muted')
- btn.set_label(' Muet')
-
- def _refresh_sink(self):
- """Met à jour la sortie et l'état du micro si changement détecté."""
- # Sortie audio
- sink = get_sink_name()
- if self.sink_label.get_label() != sink:
- self.sink_label.set_label(sink)
- vol, muted = get_volume()
- self._blocked = True
- self.scale.set_value(vol)
- self._blocked = False
- self.pct.set_label(f'{vol}%')
- self._muted = muted
- self.icon.set_label(vol_icon(vol, muted))
-
- # Micro
- mic_muted = get_mic_muted()
- if mic_muted != self._mic_muted:
- self._mic_muted = mic_muted
- if mic_muted:
- self.mic_btn.get_style_context().add_class('muted')
- self.mic_btn.set_label(' Coupé')
- else:
- self.mic_btn.get_style_context().remove_class('muted')
- self.mic_btn.set_label(' Actif')
-
- return True # continuer le timer
-
- def _on_mic_mute(self, btn):
- toggle_mic_mute()
- self._mic_muted = get_mic_muted()
- if self._mic_muted:
- btn.get_style_context().add_class('muted')
- btn.set_label(' Coupé')
- else:
- btn.get_style_context().remove_class('muted')
- btn.set_label(' Actif')
-
- def _on_key(self, _widget, event):
- if event.keyval == Gdk.KEY_Escape:
- self.destroy()
-
-
-if __name__ == '__main__':
- win = VolumePopup()
- win.connect('destroy', Gtk.main_quit)
- Gtk.main()
diff --git a/INSTALL/scripts/01-packages-apt.sh b/INSTALL/scripts/01-packages-apt.sh
index dd4faa9..f42484e 100755
--- a/INSTALL/scripts/01-packages-apt.sh
+++ b/INSTALL/scripts/01-packages-apt.sh
@@ -37,6 +37,8 @@ PACKAGES=(
python3-gi
gir1.2-gtk-3.0
gir1.2-gtklayershell-0.1
+ gir1.2-gdkpixbuf-2.0
+ gir1.2-pango-1.0
# ── Fun & utils ──────────────────────────────────────────────────────────
cmatrix
toilet