feat(media-panel): sélecteur sortie/entrée audio

- Sélecteur de sortie (SORTIE) : liste verticale des sinks disponibles,
  filtre les SUSPENDED (HDMI non branchés), actif surligné en rose
- Sélecteur d'entrée (ENTRÉE) : même logique, filtre les .monitor
  (loopbacks), garde les vrais micros même SUSPENDED
- Popup ancré à droite (plus jamais hors écran)
- LANG=C pour pactl (indépendant de la locale système)
This commit is contained in:
Tetardtek
2026-02-23 19:35:10 +01:00
parent 40850161a5
commit 2f3fc71ab7

View File

@@ -135,6 +135,31 @@ scale.audio.muted slider {
border-color: rgba(108, 112, 134, 0.60); 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) ───────────────────────────────────────────────── */ /* ── Slider luminosité (cyan) ───────────────────────────────────────────────── */
scale.bright { scale.bright {
@@ -182,8 +207,9 @@ POPUP_WIDTH = 310
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
def run(cmd, **kw): def run(cmd, env=None, **kw):
return subprocess.run(cmd, capture_output=True, text=True, timeout=2, **kw) return subprocess.run(cmd, capture_output=True, text=True, timeout=2,
env=env, **kw)
def get_sink_volume(): def get_sink_volume():
r = run(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@']) r = run(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'])
@@ -239,6 +265,71 @@ def _wob(msg):
except OSError: except OSError:
pass 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 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): def vol_icon(muted):
return '󰖁' if muted else '󰕾' return '󰖁' if muted else '󰕾'
@@ -257,19 +348,13 @@ class MediaPopup(Gtk.Window):
super().__init__() super().__init__()
self._blk = False self._blk = False
# ── Position ───────────────────────────────────────────────────────── # ── Position — ancré à droite, toujours dans l'écran ─────────────────
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)
GtkLayerShell.init_for_window(self) GtkLayerShell.init_for_window(self)
GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY) GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY)
GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.TOP, True) 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.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_keyboard_mode(self, GtkLayerShell.KeyboardMode.ON_DEMAND)
GtkLayerShell.set_exclusive_zone(self, -1) GtkLayerShell.set_exclusive_zone(self, -1)
self.set_decorated(False) self.set_decorated(False)
@@ -296,9 +381,13 @@ class MediaPopup(Gtk.Window):
self.add(box) self.add(box)
# ╔═══ SORTIE ══════════════════════════════════════════════════════════╗ # ╔═══ SORTIE ══════════════════════════════════════════════════════════╗
sinks = get_sinks()
box.pack_start(self._section_header('SORTIE', '󰕾'), False, False, 0) box.pack_start(self._section_header('SORTIE', '󰕾'), False, False, 0)
box.pack_start(self._device_label( self.sink_device_lbl = self._device_label(
get_node_name('@DEFAULT_AUDIO_SINK@')), False, False, 2) 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 = \ sink_row, self.sink_scale, self.sink_pct, self.sink_icon = \
self._slider_row(sink_vol, sink_muted, 'audio', vol_icon(sink_muted), self._slider_row(sink_vol, sink_muted, 'audio', vol_icon(sink_muted),
self._toggle_sink_mute, '@DEFAULT_AUDIO_SINK@') self._toggle_sink_mute, '@DEFAULT_AUDIO_SINK@')
@@ -309,9 +398,13 @@ class MediaPopup(Gtk.Window):
sep1.set_name('separator') sep1.set_name('separator')
box.pack_start(sep1, False, False, 0) 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._section_header('ENTRÉE', '󰍬'), False, False, 0)
box.pack_start(self._device_label( self.src_device_lbl = self._device_label(
get_node_name('@DEFAULT_AUDIO_SOURCE@')), False, False, 2) 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 = \ src_row, self.src_scale, self.src_pct, self.src_icon = \
self._slider_row(src_vol, src_muted, 'audio', mic_icon(src_muted), self._slider_row(src_vol, src_muted, 'audio', mic_icon(src_muted),
self._toggle_src_mute, '@DEFAULT_AUDIO_SOURCE@') self._toggle_src_mute, '@DEFAULT_AUDIO_SOURCE@')
@@ -351,6 +444,93 @@ class MediaPopup(Gtk.Window):
self._blk = False self._blk = False
return False return False
# ── Sélecteur de sortie ────────────────────────────────────────────────────
def _sink_selector(self, sinks):
self._sink_btns = {} # name → button
self._sink_descs = {} # name → desc
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
self.src_scale.set_value(vol)
self.src_pct.set_label(f'{vol}%')
self._blk = 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 ─────────────────────────────────────────────────────────── # ── Builders UI ───────────────────────────────────────────────────────────
def _section_header(self, label, icon): def _section_header(self, label, icon):