From 01133b78f0ab99a13d561e85e01fd6e764393702 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Mon, 23 Feb 2026 22:16:57 +0100 Subject: [PATCH] =?UTF-8?q?feat(media-popup):=20int=C3=A9grer=20contr?= =?UTF-8?q?=C3=B4leur=20MPRIS=20avec=20album=20art=20et=20contr=C3=B4les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section LECTURE apparaît automatiquement si un lecteur est actif (playerctl). - Album art chargé en arrière-plan (file:// et http/https), coins arrondis dessinés en Cairo avec fallback icône note de musique - Contrôles prev / play-pause / next via playerctl - Titre et artiste avec ellipsis, rafraîchis toutes les 2s - Suppression de vc-brightness-popup.py et vc-volume-popup.py (fusionnés) Dépendances apt ajoutées : gir1.2-gdkpixbuf-2.0, gir1.2-pango-1.0 --- .../waybar/scripts/vc-brightness-popup.py | 245 ---------- .../configs/waybar/scripts/vc-media-popup.py | 289 +++++++++++- .../configs/waybar/scripts/vc-volume-popup.py | 422 ------------------ INSTALL/scripts/01-packages-apt.sh | 2 + 4 files changed, 270 insertions(+), 688 deletions(-) delete mode 100755 INSTALL/configs/waybar/scripts/vc-brightness-popup.py delete mode 100755 INSTALL/configs/waybar/scripts/vc-volume-popup.py 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