feat(media-popup): intégrer contrôleur MPRIS avec album art et contrôles
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
This commit is contained in:
@@ -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()
|
||||
@@ -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('<span font="JetBrainsMono Nerd Font 24"></span>')
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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('<span font_family="JetBrainsMono Nerd Font" size="large"></span>')
|
||||
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()
|
||||
Reference in New Issue
Block a user