Merge features/media-panel → main

This commit is contained in:
Tetardtek
2026-02-23 22:17:25 +01:00
11 changed files with 607 additions and 730 deletions

View File

@@ -2,7 +2,7 @@
Name=Waybar
Comment=violet-chaton status bar
Type=Application
Exec=waybar
Exec=bash -c "exec 9>/tmp/waybar-start.lock; flock -n 9 || exit 0; exec waybar"
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true

View File

@@ -20,10 +20,10 @@
"custom/launcher",
"custom/sep",
"cpu",
"temperature",
"custom/cpu-temp",
"custom/gpu",
"memory",
"disk",
"custom/disks",
"custom/sep",
"custom/network"
],
@@ -77,15 +77,13 @@
"interval": 2
},
// ── Température ─────────────────────────────────────────────────────────
// ── Température CPU (auto-détection) ────────────────────────────────────
"temperature": {
"thermal-zone": 9,
"format": " {temperatureC}°",
"format-critical": " {temperatureC}°",
"critical-threshold": 80,
"tooltip": false,
"interval": 2
"custom/cpu-temp": {
"exec": "~/.config/waybar/scripts/cpu-temp.sh",
"return-type": "json",
"interval": 2,
"format": "{}"
},
// ── GPU ─────────────────────────────────────────────────────────────────
@@ -109,12 +107,13 @@
"interval": 2
},
// ── Disque ──────────────────────────────────────────────────────────────
// ── Disques (auto-détection) ─────────────────────────────────────────────
"disk": {
"format": "󰋊 {used}",
"tooltip-format": "󰋊 Disque /\n{used} / {total}\n{percentage_used}% utilisé",
"interval": 30
"custom/disks": {
"exec": "~/.config/waybar/scripts/disks.sh",
"return-type": "json",
"interval": 30,
"format": "{}"
},
// ── Réseau ──────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# cpu-temp.sh — température CPU auto-détection → JSON waybar
# Priorité 1 : thermal zone x86_pkg_temp (Intel) ou k10temp (AMD)
# Priorité 2 : hwmon coretemp / k10temp / zenpower
# Retourne vide si aucune source trouvée
emit() {
local temp=$1
local cls="normal"
(( temp >= 80 )) && cls="critical"
(( temp >= 65 && temp < 80 )) && cls="warning"
printf '{"text":" %d°","tooltip":"CPU %d°C","class":"%s","percentage":%d}\n' \
"$temp" "$temp" "$cls" "$temp"
exit 0
}
# Priorité 1 — thermal_zone x86_pkg_temp (Intel) / TCPU / k10temp (AMD)
for zone in /sys/class/thermal/thermal_zone*/; do
zone_type=$(cat "${zone}type" 2>/dev/null) || continue
case "$zone_type" in
x86_pkg_temp|k10temp|TCPU|cpu_thermal)
temp_raw=$(cat "${zone}temp" 2>/dev/null) || continue
emit $(( temp_raw / 1000 ))
;;
esac
done
# Priorité 2 — hwmon coretemp (Intel desktop) ou k10temp (AMD)
for hw in /sys/class/hwmon/hwmon*/; do
hw_name=$(cat "${hw}name" 2>/dev/null) || continue
case "$hw_name" in
coretemp|k10temp|zenpower|amd_energy)
for f in "${hw}temp1_input" "${hw}temp2_input"; do
[[ -r "$f" ]] || continue
temp_raw=$(cat "$f" 2>/dev/null) || continue
emit $(( temp_raw / 1000 ))
done
;;
esac
done
# Aucune source — module masqué
printf '{"text":"","class":"unavailable"}\n'

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# disks.sh — liste les vrais systèmes de fichiers montés → JSON waybar
# Exclut tmpfs, devtmpfs, squashfs (snap), overlay, efi, etc.
TEXT=""
TOOLTIP="󰋊 Disques\n"
while IFS= read -r line; do
fs=$(awk '{print $1}' <<< "$line")
size=$(awk '{print $2}' <<< "$line")
used=$(awk '{print $3}' <<< "$line")
avail=$(awk '{print $4}' <<< "$line")
pct=$(awk '{print $5}' <<< "$line")
mnt=$(awk '{print $6}' <<< "$line")
# Exclure mounts sans intérêt
[[ "$mnt" == /snap/* ]] && continue
[[ "$mnt" == /boot/efi ]] && continue
[[ "$mnt" == /boot ]] && continue
[[ "$mnt" == /recovery ]] && continue
[[ "$mnt" == /run* ]] && continue
[[ "$mnt" == /sys* ]] && continue
[[ "$mnt" == /proc* ]] && continue
[[ "$mnt" == /dev* ]] && continue
# Icône selon le point de montage
case "$mnt" in
/) icon="󰋊" ;;
/home) icon="󱂵" ;;
/data*) icon="󱦡" ;;
/media*) icon="󰆼" ;;
/mnt*) icon="󱛟" ;;
*) icon="󰋊" ;;
esac
# Texte compact : icône + montage court + espace utilisé
label=$(basename "$mnt")
[[ "$mnt" == "/" ]] && label="/"
[[ -n "$TEXT" ]] && TEXT+=" "
TEXT+="${icon} ${label}: ${used}"
TOOLTIP+="${icon} ${mnt}\n Utilisé : ${used} / ${size} (${pct})\n Libre : ${avail}\n"
done < <(df -hP --exclude-type=tmpfs \
--exclude-type=devtmpfs \
--exclude-type=squashfs \
--exclude-type=overlay \
--exclude-type=fuse.portal \
--exclude-type=efivarfs \
2>/dev/null | tail -n +2 | sort -k6)
if [[ -z "$TEXT" ]]; then
printf '{"text":"󰋊 N/A","tooltip":"Aucun disque détecté","class":"unavailable"}\n'
else
# Échapper uniquement les guillemets pour JSON (\n reste tel quel = saut de ligne)
TOOLTIP_JSON=$(printf '%s' "$TOOLTIP" | sed 's/"/\\"/g')
printf '{"text":"%s","tooltip":"%s"}\n' "$TEXT" "$TOOLTIP_JSON"
fi

View File

@@ -3,19 +3,17 @@
STATE_FILE="/tmp/waybar_net_state"
# Détecter l'interface active
IFACE=""
for candidate in enp7s0 enp6s0 eth0; do
if [[ -d "/sys/class/net/$candidate" && "$(cat /sys/class/net/$candidate/operstate 2>/dev/null)" == "up" ]]; then
IFACE="$candidate"; TYPE="eth"; break
# Détecter l'interface active via la route par défaut (portable sur tous les PC)
IFACE=$(ip route get 1.1.1.1 2>/dev/null \
| awk '/dev/{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' \
| head -1)
if [[ -n "$IFACE" ]]; then
if [[ -d "/sys/class/net/$IFACE/wireless" || -d "/sys/class/net/$IFACE/phy80211" ]]; then
TYPE="wifi"
else
TYPE="eth"
fi
done
if [[ -z "$IFACE" ]]; then
for candidate in wlp8s0 wlp0s20f3 wlan0; do
if [[ -d "/sys/class/net/$candidate" && "$(cat /sys/class/net/$candidate/operstate 2>/dev/null)" == "up" ]]; then
IFACE="$candidate"; TYPE="wifi"; break
fi
done
fi
if [[ -z "$IFACE" ]]; then

View File

@@ -40,6 +40,12 @@ fi
# ── Affichage JSON ────────────────────────────────────────────────────────────
# PC fixe ou VM sans gestion de profil → module masqué
if [[ ! -f /sys/firmware/acpi/platform_profile ]]; then
printf '{"text":"","class":"unavailable"}\n'
exit 0
fi
PROFILE=$(cat /sys/firmware/acpi/platform_profile 2>/dev/null || echo "unknown")
case "$PROFILE" in

View File

@@ -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()

View File

@@ -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
@@ -135,6 +142,31 @@ scale.audio.muted slider {
border-color: rgba(108, 112, 134, 0.60);
}
/* ── Sélecteur de périphérique de sortie ────────────────────────────────────── */
#device-btn {
background-color: transparent;
color: rgba(248, 248, 242, 0.65);
font-family: "JetBrainsMono Nerd Font";
font-size: 11px;
border: 1px solid transparent;
border-radius: 8px;
padding: 5px 10px;
min-width: 0;
}
#device-btn:hover {
background-color: rgba(91, 70, 113, 0.55);
color: #f8f8f2;
border-color: rgba(92, 73, 108, 0.60);
}
#device-btn.active {
background-color: rgba(255, 121, 198, 0.14);
color: #ff79c6;
border-color: rgba(255, 121, 198, 0.55);
}
/* ── Slider luminosité (cyan) ───────────────────────────────────────────────── */
scale.bright {
@@ -176,14 +208,55 @@ 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
# ── Helpers ───────────────────────────────────────────────────────────────────
def run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, timeout=2, **kw)
def run(cmd, env=None, **kw):
return subprocess.run(cmd, capture_output=True, text=True, timeout=2,
env=env, **kw)
def get_sink_volume():
r = run(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'])
@@ -239,6 +312,93 @@ def _wob(msg):
except OSError:
pass
def get_sinks():
"""Retourne [(sink_name, description, is_default)] — exclut SUSPENDED."""
env = {**os.environ, 'LANG': 'C', 'LC_ALL': 'C'}
r_default = run(['pactl', 'get-default-sink'], env=env)
default_name = r_default.stdout.strip()
r_full = run(['pactl', 'list', 'sinks'], env=env)
sinks, state, name, desc = [], None, None, None
for line in r_full.stdout.splitlines():
st = re.search(r'^\s+State:\s+(\S+)', line)
nm = re.search(r'^\s+Name:\s+(.+)$', line)
ds = re.search(r'^\s+Description:\s+(.+)$', line)
if st: state = st.group(1)
elif nm: name = nm.group(1).strip()
elif ds and name:
desc = ds.group(1).strip()
if state != 'SUSPENDED':
sinks.append((name, desc, name == default_name))
state, name, desc = None, None, None
return sinks
def set_default_sink(name):
run(['pactl', 'set-default-sink', name])
def get_sources():
"""Retourne [(source_name, description, is_default)] — exclut les .monitor."""
env = {**os.environ, 'LANG': 'C', 'LC_ALL': 'C'}
r_default = run(['pactl', 'get-default-source'], env=env)
default_name = r_default.stdout.strip()
r_full = run(['pactl', 'list', 'sources'], env=env)
sources, name, desc = [], None, None
for line in r_full.stdout.splitlines():
nm = re.search(r'^\s+Name:\s+(.+)$', line)
ds = re.search(r'^\s+Description:\s+(.+)$', line)
if nm:
name = nm.group(1).strip()
elif ds and name:
desc = ds.group(1).strip()
if '.monitor' not in name:
sources.append((name, desc, name == default_name))
name, desc = None, None
return 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 '󰥰'
if 'usb' in s: return '󱡬'
if 'headset' in s or 'headphone' in s: return '󰋎'
return '󰍬'
def sink_icon(name, desc):
s = (name + desc).lower()
if 'hdmi' in s or 'dp-' in s or 'displayport' in s: return '󰡁'
if 'bluetooth' in s or 'bluez' in s: return '󰥰'
if 'usb' in s: return '󱡬'
if 'headphone' in s or 'headset' in s: return '󰋋'
return '󰓃'
def short_desc(desc, maxlen=16):
return desc if len(desc) <= maxlen else desc[:maxlen - 1] + ''
def vol_icon(muted):
return '󰖁' if muted else '󰕾'
@@ -250,26 +410,109 @@ 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
# ── Position ─────────────────────────────────────────────────────────
display = Gdk.Display.get_default()
monitor = display.get_primary_monitor() if display else None
screen_w = monitor.get_geometry().width if monitor else 1920
module_center = screen_w - 16 - 210
margin_left = max(0, module_center - POPUP_WIDTH // 2)
self._blk = False
self._has_mpris = False
# ── Position — ancré à droite, toujours dans l'écran ─────────────────
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_anchor(self, GtkLayerShell.Edge.RIGHT, True)
GtkLayerShell.set_margin(self, GtkLayerShell.Edge.TOP, 66)
GtkLayerShell.set_margin(self, GtkLayerShell.Edge.LEFT, margin_left)
GtkLayerShell.set_margin(self, GtkLayerShell.Edge.RIGHT, 12)
GtkLayerShell.set_keyboard_mode(self, GtkLayerShell.KeyboardMode.ON_DEMAND)
GtkLayerShell.set_exclusive_zone(self, -1)
self.set_decorated(False)
@@ -295,10 +538,22 @@ 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)
box.pack_start(self._device_label(
get_node_name('@DEFAULT_AUDIO_SINK@')), False, False, 2)
self.sink_device_lbl = self._device_label(
get_node_name('@DEFAULT_AUDIO_SINK@'))
box.pack_start(self.sink_device_lbl, False, False, 2)
if len(sinks) > 1:
box.pack_start(self._sink_selector(sinks), False, False, 4)
sink_row, self.sink_scale, self.sink_pct, self.sink_icon = \
self._slider_row(sink_vol, sink_muted, 'audio', vol_icon(sink_muted),
self._toggle_sink_mute, '@DEFAULT_AUDIO_SINK@')
@@ -309,9 +564,13 @@ class MediaPopup(Gtk.Window):
sep1.set_name('separator')
box.pack_start(sep1, False, False, 0)
sources = get_sources()
box.pack_start(self._section_header('ENTRÉE', '󰍬'), False, False, 0)
box.pack_start(self._device_label(
get_node_name('@DEFAULT_AUDIO_SOURCE@')), False, False, 2)
self.src_device_lbl = self._device_label(
get_node_name('@DEFAULT_AUDIO_SOURCE@'))
box.pack_start(self.src_device_lbl, False, False, 2)
if len(sources) > 1:
box.pack_start(self._source_selector(sources), False, False, 4)
src_row, self.src_scale, self.src_pct, self.src_icon = \
self._slider_row(src_vol, src_muted, 'audio', mic_icon(src_muted),
self._toggle_src_mute, '@DEFAULT_AUDIO_SOURCE@')
@@ -333,23 +592,167 @@ 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."""
# ── 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 = {}
self._sink_descs = {}
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
for name, desc, is_default in sinks:
self._sink_descs[name] = desc
btn = Gtk.Button()
btn.set_name('device-btn')
btn.set_hexpand(True)
lbl = Gtk.Label(label=self._sink_label(name, desc, is_default))
lbl.set_halign(Gtk.Align.START)
btn.add(lbl)
if is_default:
btn.get_style_context().add_class('active')
btn.connect('clicked', self._on_sink_selected, name)
col.pack_start(btn, False, True, 0)
self._sink_btns[name] = btn
return col
def _sink_label(self, name, desc, active):
check = ' ' if active else ''
return f'{sink_icon(name, desc)} {desc}{check}'
def _source_selector(self, sources):
self._src_btns = {}
self._src_descs = {}
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
for name, desc, is_default in sources:
self._src_descs[name] = desc
btn = Gtk.Button()
btn.set_name('device-btn')
btn.set_hexpand(True)
lbl = Gtk.Label(label=self._src_label(name, desc, is_default))
lbl.set_halign(Gtk.Align.START)
btn.add(lbl)
if is_default:
btn.get_style_context().add_class('active')
btn.connect('clicked', self._on_source_selected, name)
col.pack_start(btn, False, True, 0)
self._src_btns[name] = btn
return col
def _src_label(self, name, desc, active):
check = ' ' if active else ''
return f'{source_icon(name, desc)} {desc}{check}'
def _on_source_selected(self, _btn, name):
for n, b in self._src_btns.items():
b.get_style_context().remove_class('active')
b.get_child().set_label(self._src_label(n, self._src_descs[n], False))
self._src_btns[name].get_style_context().add_class('active')
self._src_btns[name].get_child().set_label(
self._src_label(name, self._src_descs[name], True))
set_default_source(name)
self.src_device_lbl.set_label(self._src_descs[name])
vol, muted = get_source_volume()
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.src_scale.set_value(vol)
self.src_pct.set_label(f'{vol}%')
self._blk = False
return False
self._src_muted = muted
self._apply_mute(self.src_scale, self.src_icon, muted, mic_icon)
subprocess.Popen(['pkill', '-RTMIN+1', 'waybar'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _on_sink_selected(self, _btn, name):
for n, b in self._sink_btns.items():
b.get_style_context().remove_class('active')
b.get_child().set_label(
self._sink_label(n, self._sink_descs[n], False))
self._sink_btns[name].get_style_context().add_class('active')
self._sink_btns[name].get_child().set_label(
self._sink_label(name, self._sink_descs[name], True))
set_default_sink(name)
self.sink_device_lbl.set_label(self._sink_descs[name])
vol, muted = get_sink_volume()
self._blk = True
self.sink_scale.set_value(vol)
self.sink_pct.set_label(f'{vol}%')
self._blk = False
self._sink_muted = muted
self._apply_mute(self.sink_scale, self.sink_icon, muted, vol_icon)
subprocess.Popen(['pkill', '-RTMIN+1', 'waybar'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# ── Builders UI ───────────────────────────────────────────────────────────
@@ -376,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:
@@ -384,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)
@@ -396,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)
@@ -469,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()
@@ -489,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

View File

@@ -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()

View File

@@ -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

View File

@@ -102,9 +102,11 @@ window#waybar {
#cpu,
#temperature,
#custom-cpu-temp,
#custom-gpu,
#memory,
#disk,
#custom-disks,
#custom-network,
#clock,
#custom-date,
@@ -140,7 +142,8 @@ window#waybar {
/* ── Température ──────────────────────────────────────────────────────────── */
#temperature {
#temperature,
#custom-cpu-temp {
color: rgba(139, 233, 253, 0.60);
font-size: 11px;
font-weight: normal;
@@ -148,12 +151,17 @@ window#waybar {
padding-right: 10px;
}
#temperature.critical {
#temperature.critical,
#custom-cpu-temp.critical {
color: #f38ba8;
font-weight: bold;
animation: pulse-critical 0.8s linear infinite;
}
#custom-cpu-temp.warning {
color: #f9e2af;
}
/* ── GPU ──────────────────────────────────────────────────────────────────── */
#custom-gpu {
@@ -186,7 +194,8 @@ window#waybar {
/* ── Disque ───────────────────────────────────────────────────────────────── */
#disk {
#disk,
#custom-disks {
color: rgba(255, 121, 198, 0.70);
font-size: 11px;
font-weight: normal;
@@ -405,6 +414,8 @@ tooltip label {
#cpu:hover,
#memory:hover,
#disk:hover,
#custom-disks:hover,
#custom-cpu-temp:hover,
#custom-network:hover,
#wireplumber:hover,
#backlight:hover,