diff --git a/INSTALL/configs/autostart/waybar.desktop b/INSTALL/configs/autostart/waybar.desktop
index 3185bd4..dd26167 100644
--- a/INSTALL/configs/autostart/waybar.desktop
+++ b/INSTALL/configs/autostart/waybar.desktop
@@ -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
diff --git a/INSTALL/configs/waybar/config b/INSTALL/configs/waybar/config
index 611082e..80c1eff 100644
--- a/INSTALL/configs/waybar/config
+++ b/INSTALL/configs/waybar/config
@@ -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 ──────────────────────────────────────────────────────────────
diff --git a/INSTALL/configs/waybar/scripts/cpu-temp.sh b/INSTALL/configs/waybar/scripts/cpu-temp.sh
new file mode 100755
index 0000000..c6c99f0
--- /dev/null
+++ b/INSTALL/configs/waybar/scripts/cpu-temp.sh
@@ -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'
diff --git a/INSTALL/configs/waybar/scripts/disks.sh b/INSTALL/configs/waybar/scripts/disks.sh
new file mode 100755
index 0000000..6764dfd
--- /dev/null
+++ b/INSTALL/configs/waybar/scripts/disks.sh
@@ -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
diff --git a/INSTALL/configs/waybar/scripts/network.sh b/INSTALL/configs/waybar/scripts/network.sh
index a3350f5..7c4729c 100755
--- a/INSTALL/configs/waybar/scripts/network.sh
+++ b/INSTALL/configs/waybar/scripts/network.sh
@@ -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
diff --git a/INSTALL/configs/waybar/scripts/power-profile.sh b/INSTALL/configs/waybar/scripts/power-profile.sh
index 7de609d..3ccde8e 100755
--- a/INSTALL/configs/waybar/scripts/power-profile.sh
+++ b/INSTALL/configs/waybar/scripts/power-profile.sh
@@ -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
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 3415c42..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
@@ -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('')
+ 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
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
diff --git a/INSTALL/themes/violet-chaton-waybar.css b/INSTALL/themes/violet-chaton-waybar.css
index 1d121f5..a5bc567 100644
--- a/INSTALL/themes/violet-chaton-waybar.css
+++ b/INSTALL/themes/violet-chaton-waybar.css
@@ -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,