From 5f8f38840b2dd90db1d295f227e1ad70948d08e7 Mon Sep 17 00:00:00 2001 From: Sarthak <118442597+sarthak1337-alt@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:57:23 +0530 Subject: [PATCH] control panel: switch audio devices through wpctl, flag unplugged ones pactl set-default-sink/source is a no-op under PipeWire - WirePlumber owns the default and only honours wpctl, which takes node ids from a different namespace than pactl's indices. The panel now resolves ids from wpctl status and verifies the default actually moved instead of reporting success blindly. A device whose ports are all 'not available' cannot become the default at all; those are greyed and labelled (unplugged) rather than silently failing. Speaker and mic glyphs on the home sliders 14px -> 19px. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cn3dHfbgNYdbZvzb1b3GQt --- .config/scripts/control-panel.py | 105 +++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 27 deletions(-) diff --git a/.config/scripts/control-panel.py b/.config/scripts/control-panel.py index 6fae5f5..18d486a 100755 --- a/.config/scripts/control-panel.py +++ b/.config/scripts/control-panel.py @@ -74,11 +74,13 @@ window.panel { background-color: transparent; } border: none; border-radius: 10px; color: #e9eefb; - font-size: 14px; - padding: 10px 12px; + font-size: 19px; + padding: 9px 12px; } .rowbtn:hover { background-color: rgba(146, 185, 234, 0.17); } -.rowbtn.chosen { background-color: rgba(146, 185, 234, 0.22); color: #cfe0f7; } +.rowbtn.chosen { background-color: rgba(146, 185, 234, 0.22); color: #cfe0f7; } +.rowbtn.unavail { color: #6f6889; } +.rowbtn.unavail label { color: #6f6889; } .pw { background-image: none; background-color: rgba(38, 32, 58, 0.78); @@ -157,19 +159,49 @@ def mic_get(): return (round(float(m.group(1)) * 100) if m else 0, "MUTED" in out) +def wp_ids(kind): + """Map description -> WirePlumber node id for the Audio section. + + pactl indices are a different namespace from PipeWire node ids, and + `wpctl set-default` only accepts the latter.""" + out = sh("wpctl status") + audio = out.split("Audio", 1)[-1].split("Video", 1)[0] + ids, grab = {}, False + for line in audio.splitlines(): + if re.search(r"\b(Sinks|Sources):", line): + grab = kind.rstrip("s").capitalize() + "s:" in line + continue + if re.search(r"\b(Devices|Filters|Streams|Sink endpoints|Source endpoints):", line): + grab = False + continue + if grab: + m = re.match(r"[^0-9*]*(\*?)\s*(\d+)\.\s+(.+?)(?:\s+\[vol.*)?$", line) + if m: + ids[m.group(3).strip()] = int(m.group(2)) + return ids + + def _pactl_devices(kind): - """kind: 'sinks' or 'sources'. Returns [(name, description, is_default)].""" + """[(name, description, is_default, available, node_id)] for sinks/sources.""" default = sh(f"pactl get-default-{'sink' if kind == 'sinks' else 'source'}") - out, name, desc, items = sh(f"pactl list {kind}"), None, None, [] - for line in out.splitlines(): - line = line.strip() - if line.startswith("Name:"): - name = line.split(":", 1)[1].strip() - elif line.startswith("Description:"): - desc = line.split(":", 1)[1].strip() - if name and not name.startswith("alsa_output.platform-snd_dummy"): - items.append((name, desc, name == default)) - name = None + node_ids = wp_ids(kind) + out, items = sh(f"pactl list {kind}"), [] + name = desc = None + ports, has_ports = [], False + blocks = re.split(r"\n(?=\w)", out) + for block in blocks: + m = re.search(r"^\s*Name:\s*(\S+)", block, re.M) + d = re.search(r"^\s*Description:\s*(.+)$", block, re.M) + if not m or not d: + continue + name, desc = m.group(1), d.group(1).strip() + port_lines = re.findall(r"^\s+\S+:.*\((?:type:.*?)?priority:.*?\)\s*$", + block, re.M) + has_ports = bool(port_lines) + # a device whose every port is unplugged cannot become the default + available = (not has_ports) or any("not available" not in p for p in port_lines) + items.append((name, desc, name == default, available, + node_ids.get(desc))) return items @@ -478,7 +510,7 @@ class Panel(Gtk.Window): lbl.set_margin_bottom(2) return lbl - def device_row(self, name, desc, active, kind): + def device_row(self, name, desc, active, kind, available=True, node_id=None): b = Gtk.Button() b.get_style_context().add_class("rowbtn") if active: @@ -491,7 +523,11 @@ class Panel(Gtk.Window): t.set_ellipsize(3) h.pack_start(t, True, True, 0) b.add(h) - b.connect("clicked", lambda *_: self.set_device(kind, name)) + if not available: + b.get_style_context().add_class("unavail") + t.set_text(f"{desc} (unplugged)") + b.connect("clicked", + lambda *_: self.set_device(kind, name, node_id, available)) return b def app_row(self, idx, app, vol, muted, show_label=True, icon=None): @@ -527,13 +563,28 @@ class Panel(Gtk.Window): card.add(row) return card - def set_device(self, kind, name): - sh(f"pactl set-default-{kind} {name}") - if kind == "sink": - # follow existing streams over to the new output - for idx, *_ in sink_inputs(): - sh(f"pactl move-sink-input {idx} {name}") - notify("Audio", f"{'Output' if kind == 'sink' else 'Input'} switched") + def set_device(self, kind, name, node_id=None, available=True): + label = "Output" if kind == "sink" else "Input" + if not available: + notify("Audio", f"{label} unavailable - nothing plugged into that jack") + return + before = sh(f"pactl get-default-{kind}") + # pactl set-default-* is a no-op under PipeWire; WirePlumber owns the + # default and only honours wpctl with a node id + if node_id is not None: + sh(f"wpctl set-default {node_id}") + else: + sh(f"pactl set-default-{kind} {name}") + GLib.usleep(250000) + after = sh(f"pactl get-default-{kind}") + if after == before and before != name: + notify("Audio", f"{label} did not switch", + "WirePlumber refused it, usually an unplugged port") + else: + if kind == "sink": + for idx, *_ in sink_inputs(): # move streams to the new output + sh(f"pactl move-sink-input {idx} {name}") + notify("Audio", f"{label} switched") self.fill_audio() self.refresh() @@ -554,17 +605,17 @@ class Panel(Gtk.Window): self.audio_box.add(self.section("Output")) card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) card.get_style_context().add_class("card") - for name, desc, active in _pactl_devices("sinks"): - card.add(self.device_row(name, desc, active, "sink")) + for name, desc, active, avail, nid in _pactl_devices("sinks"): + card.add(self.device_row(name, desc, active, "sink", avail, nid)) self.audio_box.add(card) self.audio_box.add(self.section("Input")) card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) card.get_style_context().add_class("card") - for name, desc, active in _pactl_devices("sources"): + for name, desc, active, avail, nid in _pactl_devices("sources"): if ".monitor" in name: continue - card.add(self.device_row(name, desc, active, "source")) + card.add(self.device_row(name, desc, active, "source", avail, nid)) self.audio_box.add(card) apps = sink_inputs()