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: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #e9eefb;
|
color: #e9eefb;
|
||||||
font-size: 14px;
|
font-size: 19px;
|
||||||
padding: 10px 12px;
|
padding: 9px 12px;
|
||||||
}
|
}
|
||||||
.rowbtn:hover { background-color: rgba(146, 185, 234, 0.17); }
|
.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 {
|
.pw {
|
||||||
background-image: none;
|
background-image: none;
|
||||||
background-color: rgba(38, 32, 58, 0.78);
|
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)
|
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):
|
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'}")
|
default = sh(f"pactl get-default-{'sink' if kind == 'sinks' else 'source'}")
|
||||||
out, name, desc, items = sh(f"pactl list {kind}"), None, None, []
|
node_ids = wp_ids(kind)
|
||||||
for line in out.splitlines():
|
out, items = sh(f"pactl list {kind}"), []
|
||||||
line = line.strip()
|
name = desc = None
|
||||||
if line.startswith("Name:"):
|
ports, has_ports = [], False
|
||||||
name = line.split(":", 1)[1].strip()
|
blocks = re.split(r"\n(?=\w)", out)
|
||||||
elif line.startswith("Description:"):
|
for block in blocks:
|
||||||
desc = line.split(":", 1)[1].strip()
|
m = re.search(r"^\s*Name:\s*(\S+)", block, re.M)
|
||||||
if name and not name.startswith("alsa_output.platform-snd_dummy"):
|
d = re.search(r"^\s*Description:\s*(.+)$", block, re.M)
|
||||||
items.append((name, desc, name == default))
|
if not m or not d:
|
||||||
name = None
|
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
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -478,7 +510,7 @@ class Panel(Gtk.Window):
|
||||||
lbl.set_margin_bottom(2)
|
lbl.set_margin_bottom(2)
|
||||||
return lbl
|
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 = Gtk.Button()
|
||||||
b.get_style_context().add_class("rowbtn")
|
b.get_style_context().add_class("rowbtn")
|
||||||
if active:
|
if active:
|
||||||
|
|
@ -491,7 +523,11 @@ class Panel(Gtk.Window):
|
||||||
t.set_ellipsize(3)
|
t.set_ellipsize(3)
|
||||||
h.pack_start(t, True, True, 0)
|
h.pack_start(t, True, True, 0)
|
||||||
b.add(h)
|
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
|
return b
|
||||||
|
|
||||||
def app_row(self, idx, app, vol, muted, show_label=True, icon=None):
|
def app_row(self, idx, app, vol, muted, show_label=True, icon=None):
|
||||||
|
|
@ -527,13 +563,28 @@ class Panel(Gtk.Window):
|
||||||
card.add(row)
|
card.add(row)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
def set_device(self, kind, name):
|
def set_device(self, kind, name, node_id=None, available=True):
|
||||||
sh(f"pactl set-default-{kind} {name}")
|
label = "Output" if kind == "sink" else "Input"
|
||||||
if kind == "sink":
|
if not available:
|
||||||
# follow existing streams over to the new output
|
notify("Audio", f"{label} unavailable - nothing plugged into that jack")
|
||||||
for idx, *_ in sink_inputs():
|
return
|
||||||
sh(f"pactl move-sink-input {idx} {name}")
|
before = sh(f"pactl get-default-{kind}")
|
||||||
notify("Audio", f"{'Output' if kind == 'sink' else 'Input'} switched")
|
# 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.fill_audio()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
|
|
@ -554,17 +605,17 @@ class Panel(Gtk.Window):
|
||||||
self.audio_box.add(self.section("Output"))
|
self.audio_box.add(self.section("Output"))
|
||||||
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
card.get_style_context().add_class("card")
|
card.get_style_context().add_class("card")
|
||||||
for name, desc, active in _pactl_devices("sinks"):
|
for name, desc, active, avail, nid in _pactl_devices("sinks"):
|
||||||
card.add(self.device_row(name, desc, active, "sink"))
|
card.add(self.device_row(name, desc, active, "sink", avail, nid))
|
||||||
self.audio_box.add(card)
|
self.audio_box.add(card)
|
||||||
|
|
||||||
self.audio_box.add(self.section("Input"))
|
self.audio_box.add(self.section("Input"))
|
||||||
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
card.get_style_context().add_class("card")
|
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:
|
if ".monitor" in name:
|
||||||
continue
|
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)
|
self.audio_box.add(card)
|
||||||
|
|
||||||
apps = sink_inputs()
|
apps = sink_inputs()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue