# 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 } # Return dock-class sinks whose current port is usable. PipeWire often # creates the HDMI/DP node before the cable is plugged and later flips its # card/port availability, so presence alone is not a hotplug signal. dock_sinks() { $PACTL --format=json list sinks \ | $JQ -r --arg re '^(${dockSinkRe})$' ' .[] | select(.name | test($re)) | select( ((.ports // []) | length) == 0 or any(.ports[]?; (.availability // "unknown") != "not available") ) | "\(.name)\t\(.description)"' } adopt_first_dock() { dock_sinks | while IFS="$TAB" read -r name desc; do [ -n "$name" ] || continue switch_to "$name" "$desc" break done } # 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 adopt_first_dock fi # Event loop: handle both a genuinely new sink and the common case where # an existing HDMI/DP card changes profile/port availability. Deliberately # ignore ordinary sink "change" events (volume/mute) and server-default # changes, so a manual speaker pick while docked remains sticky. LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do case "$ev:$kind" in "'new':sink"|"'new':card"|"'change':card") ;; *) continue ;; esac sleep 0.75 # let PipeWire finish the profile/route transition adopt_first_dock 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" ]; }; }; }