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:
parent
d6129334b1
commit
5f8f38840b
1 changed files with 78 additions and 27 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue