feat(audio,menu): dock audio auto-follow watcher + docked Display/Audio menu rows
Some checks failed
Check / eval (push) Failing after 1m41s
Some checks failed
Check / eval (push) Failing after 1m41s
Bernardo's dock test (the #87 V3): audio stayed on EasyEffects→speakers. Root cause, established live: WirePlumber's stored default (default-nodes state, written by any past explicit pick) outranks the priority.session rules, so #87 alone can never move the default on a real machine; EasyEffects 8 follows the default device (kcfg default true, verified by watching its output links). Fix: nomarchy-dock-audio (modules/home/dock-audio.nix, nomarchy.dockAudio.enable, default on) — a pactl-subscribe watcher that set-default-sinks new dock-class sinks (regex data imported from modules/nixos/dock-audio-rules.nix, single source) with a toast, plus a startup sweep for login-while-docked that never overrides a manual in-dock pick. Unplug falls back by priority. Menus: Display gains docked rows — "Laptop screen off · everything → <ext>" (live-only disable; a persisted one could black-screen an undocked boot), "Screen on · <name>", "Move workspace · → next monitor", "Swap workspaces" (exactly-2 gate); Audio gains a self-gated "Send output → <dock sink>" quick row. All self-gate on real outputs. Verified: V2-equivalent on live hardware (no dock available) — watcher followed a fake dock-named null sink and fell back on unload, control run without the watcher stayed pinned (proving the stored-default diagnosis), EE output links moved mid-playback; generated menu bash -n green, dock-row jq exercised on real pactl JSON, dispatchers probed on live Hyprland; flake check + option-docs + template-sot green. V3 pending: real-dock retest ×2 in HARDWARE-QUEUE (watcher keys on new-sink events; pre-existing nodes that only flip route availability would need the queued diagnosis data). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
90
modules/home/dock-audio.nix
Normal file
90
modules/home/dock-audio.nix
Normal file
@@ -0,0 +1,90 @@
|
||||
# Automatic audio-output follow for docks / external monitors (#87 round
|
||||
# 2). The WirePlumber priority rules (modules/nixos/dock-audio-rules.nix)
|
||||
# only decide among sinks when there is NO stored user default — but
|
||||
# WirePlumber persists every explicit pick (menu, pavucontrol, pactl) in
|
||||
# its default-nodes state, and that stored default outranks priority. So
|
||||
# on any machine where a device was ever picked, docking never moves the
|
||||
# default — Bernardo's T14s dock test, 2026-07-12. This watcher makes the
|
||||
# switch explicit: when a dock-class sink appears (same regexes as the
|
||||
# wireplumber rules — imported from that file, single source of truth) it
|
||||
# `pactl set-default-sink`s it and toasts. EasyEffects needs no handling:
|
||||
# EE 8's useDefaultOutputDevice defaults to true, so its output follows
|
||||
# the default device and effects stay in the chain (a user who pinned a
|
||||
# device inside EE keeps their pin — that's an explicit choice). Unplug
|
||||
# needs no handling either: the stored dock sink vanishes and WirePlumber
|
||||
# falls back by priority to the built-in output. A manual pick made while
|
||||
# docked sticks until the next plug event (a plug is treated as intent).
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.nomarchy;
|
||||
rules = import ../nixos/dock-audio-rules.nix;
|
||||
dockSinkRe = lib.concatStringsSep "|"
|
||||
(map (m: lib.removePrefix "~" m."node.name")
|
||||
(lib.concatMap (r: r.matches) rules."monitor.alsa.rules"));
|
||||
|
||||
watcher = pkgs.writeShellScript "nomarchy-dock-audio" ''
|
||||
set -euo pipefail
|
||||
PACTL=${pkgs.pulseaudio}/bin/pactl
|
||||
JQ=${pkgs.jq}/bin/jq
|
||||
TAB=$(printf '\t')
|
||||
|
||||
# Self-gate: no PipeWire pulse socket (nomarchy.audio off, TTY-only
|
||||
# session) means nothing to watch — exit clean, stay dead.
|
||||
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
for _ in $(seq 60); do [ -e "$rt/pulse/native" ] && break; sleep 1; done
|
||||
[ -e "$rt/pulse/native" ] || exit 0
|
||||
|
||||
is_dock() {
|
||||
printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -qE '^(${dockSinkRe})$'
|
||||
}
|
||||
|
||||
switch_to() { # $1 = sink node name, $2 = description
|
||||
[ "$($PACTL get-default-sink)" = "$1" ] && return 0
|
||||
$PACTL set-default-sink "$1" 2>/dev/null || return 0
|
||||
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \
|
||||
"Output → ''${2:-$1}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Startup sweep (login/relogin while already docked): adopt a dock
|
||||
# sink only when the current default is NOT one — a service restart
|
||||
# must not override a manual in-dock pick.
|
||||
if ! is_dock "$($PACTL get-default-sink)"; then
|
||||
$PACTL --format=json list sinks \
|
||||
| $JQ -r '.[] | "\(.name)\t\(.description)"' \
|
||||
| while IFS="$TAB" read -r name desc; do
|
||||
if is_dock "$name"; then switch_to "$name" "$desc"; break; fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Event loop: `pactl subscribe` prints "Event 'new' on sink #NN".
|
||||
LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do
|
||||
[ "$ev" = "'new'" ] && [ "$kind" = "sink" ] || continue
|
||||
id=''${id#\#}
|
||||
case "$id" in *[!0-9]*|"") continue ;; esac
|
||||
sleep 0.5 # let the node's description/routes settle
|
||||
line=$($PACTL --format=json list sinks | $JQ -r --argjson i "$id" \
|
||||
'.[] | select(.index == $i) | "\(.name)\t\(.description)"')
|
||||
[ -n "$line" ] || continue
|
||||
name=''${line%%"$TAB"*}; desc=''${line#*"$TAB"}
|
||||
if is_dock "$name"; then switch_to "$name" "$desc"; fi
|
||||
done
|
||||
'';
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.dockAudio.enable {
|
||||
systemd.user.services.nomarchy-dock-audio = {
|
||||
Unit = {
|
||||
Description = "Move the default audio sink to dock/monitor outputs on hotplug";
|
||||
After = [ "graphical-session.target" ];
|
||||
PartOf = [ "graphical-session.target" ];
|
||||
};
|
||||
Service = {
|
||||
ExecStart = "${watcher}";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 2;
|
||||
};
|
||||
Install.WantedBy = [ "graphical-session.target" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user