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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cn3dHfbgNYdbZvzb1b3GQt
This commit is contained in:
Sarthak 2026-09-04 08:57:23 +05:30
parent d6129334b1
commit 5f8f38840b

View file

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