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:
@@ -28,6 +28,7 @@
|
||||
./mime.nix # default applications (mimeapps.list), degrades with the suite
|
||||
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
|
||||
./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds
|
||||
./dock-audio.nix # default sink follows dock/monitor audio on hotplug (#87)
|
||||
./first-boot.nix # one-shot "you're set" toast on first session (#81)
|
||||
./satty.nix # satty screenshot annotation tool, themed
|
||||
];
|
||||
|
||||
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" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -364,6 +364,7 @@ in
|
||||
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
|
||||
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
|
||||
batteryNotify.enable = lib.mkEnableOption "low-battery notifications at the bar's thresholds (25% low, 10% critical — that one stays up until dismissed); self-gating, a silent no-op on machines without a battery" // { default = true; };
|
||||
dockAudio.enable = lib.mkEnableOption "automatic default-output switch to dock/monitor audio (HDMI/DisplayPort/USB sinks) on hotplug, with a toast; EasyEffects follows along, and unplugging falls back to the built-in output" // { default = true; };
|
||||
firstBootWelcome.enable = lib.mkEnableOption "one dismissible \"you're set\" toast on the first session (menu/themes/keys + network pointer); marker is settings.firstBootShown in the flake checkout" // { default = true; };
|
||||
idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; };
|
||||
yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };
|
||||
|
||||
@@ -15,6 +15,14 @@ let
|
||||
|
||||
px = n: mkLiteral "${toString n}px";
|
||||
|
||||
# Dock-class audio sinks (HDMI/DP/USB) — the same regex data the
|
||||
# WirePlumber priority rules and the dock-audio watcher consume
|
||||
# (modules/nixos/dock-audio-rules.nix is the single source).
|
||||
dockSinkRe = lib.concatStringsSep "|"
|
||||
(map (m: lib.removePrefix "~" m."node.name")
|
||||
(lib.concatMap (r: r.matches)
|
||||
(import ../nixos/dock-audio-rules.nix)."monitor.alsa.rules"));
|
||||
|
||||
# Per-theme override probe (same convention as waybar.css).
|
||||
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
|
||||
hasRasiOverride = builtins.pathExists rasiOverride;
|
||||
@@ -498,13 +506,30 @@ ${themeRows}
|
||||
if [ ! -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ]; then
|
||||
notify-send "Audio" "No PipeWire/PulseAudio socket (nomarchy.audio off?)."; exit 0
|
||||
fi
|
||||
# Docked quick row: the first dock-class sink (HDMI/DP/USB —
|
||||
# dockSinkRe, single source with the wireplumber rules) that is
|
||||
# not already the default. One pick sends output there; the
|
||||
# dock-audio watcher normally does this on plug, so this row is
|
||||
# the manual override / catch-up. pactl by store path — it is
|
||||
# not on PATH (PipeWire stack, wpctl only).
|
||||
sinks=$(${pkgs.pulseaudio}/bin/pactl --format=json list sinks 2>/dev/null || true)
|
||||
cursink=$(${pkgs.pulseaudio}/bin/pactl get-default-sink 2>/dev/null || true)
|
||||
dockname=$(printf '%s' "$sinks" | jq -r --arg cur "$cursink" --arg re '${dockSinkRe}' \
|
||||
'[.[] | select((.name | test("^(" + $re + ")$")) and .name != $cur)][0].name // empty')
|
||||
dockdesc=
|
||||
[ -n "$dockname" ] && dockdesc=$(printf '%s' "$sinks" \
|
||||
| jq -r --arg n "$dockname" '[.[] | select(.name == $n)][0].description // empty')
|
||||
choice=$( {
|
||||
[ -n "$dockname" ] && row "Send output → ''${dockdesc:-$dockname}" video-display
|
||||
row "Output device" audio-volume-high
|
||||
row "Input device" audio-input-microphone
|
||||
back
|
||||
} | rofi_menu -show-icons -p Audio) || exit 0
|
||||
case "$choice" in
|
||||
"$BACK") exec "$0" system ;;
|
||||
"Send output"*)
|
||||
${pkgs.pulseaudio}/bin/pactl set-default-sink "$dockname" \
|
||||
&& notify-send "Audio" "Output → ''${dockdesc:-$dockname}" ;;
|
||||
*Output*) exec rofi-pulse-select sink ;;
|
||||
*Input*) exec rofi-pulse-select source ;;
|
||||
esac ;;
|
||||
@@ -522,6 +547,14 @@ ${themeRows}
|
||||
|| { notify-send "Display" "Hyprland is not running."; exit 0; }
|
||||
mons=$(hyprctl monitors -j)
|
||||
|
||||
# Docked context: which outputs are active, which is the built-in
|
||||
# panel, is anything currently soft-disabled (monitors all shows
|
||||
# disabled outputs; plain monitors does not).
|
||||
nActive=$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)
|
||||
internal=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)"))][0] // empty')
|
||||
external=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)") | not)][0] // empty')
|
||||
offmon=$(printf '%s' "$(hyprctl monitors all -j)" | jq -r '[.[] | select(.disabled)][0].name // empty')
|
||||
|
||||
# Choose the output (skip the chooser when only one is connected and
|
||||
# there are no profiles — the Profiles row needs the chooser to live in).
|
||||
# Where the mode picker's Back returns to: the output chooser when it
|
||||
@@ -530,14 +563,54 @@ ${themeRows}
|
||||
# `display` would just re-open this same mode picker (item 40).
|
||||
modeBack=system
|
||||
profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1
|
||||
if [ -n "$profs" ] || [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then
|
||||
if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then
|
||||
name=$( {
|
||||
[ -n "$profs" ] && printf 'Profiles ›\n'
|
||||
# Docked rows — self-gated: never offer to disable the only
|
||||
# active display; the re-enable row appears whenever an
|
||||
# output is soft-off (so an undocked laptop can recover too).
|
||||
[ "$nActive" -gt 1 ] && [ -n "$internal" ] && [ -n "$external" ] \
|
||||
&& printf 'Laptop screen off · everything → %s\n' "$external"
|
||||
[ -n "$offmon" ] && printf 'Screen on · %s\n' "$offmon"
|
||||
[ "$nActive" -gt 1 ] && printf 'Move workspace · → next monitor\n'
|
||||
[ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s ⇄ %s\n' \
|
||||
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
|
||||
"$(printf '%s' "$mons" | jq -r '.[1].name')"
|
||||
printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"'
|
||||
printf '%s\n' "$BACK"
|
||||
} | rofi_menu -p Display ) || exit 0
|
||||
[ "$name" = "$BACK" ] && exec "$0" system
|
||||
[ "$name" = "Profiles ›" ] && exec "$0" display-profile
|
||||
case "$name" in
|
||||
"Laptop screen off"*)
|
||||
# Live-only on purpose: Hyprland moves the panel's
|
||||
# workspaces to the remaining output on disable; persisting
|
||||
# a disable could black-screen an undocked boot, so the
|
||||
# declared rule returns at the next relogin/switch.
|
||||
if hyprctl keyword monitor "$internal,disable" >/dev/null 2>&1; then
|
||||
notify-send "Display" "Laptop screen off — workspaces moved to $external (this session; turn it back on from this menu)."
|
||||
else
|
||||
notify-send "Display" "Could not disable $internal."
|
||||
fi
|
||||
exit 0 ;;
|
||||
"Screen on"*)
|
||||
# preferred/auto/auto is good enough live; the declared
|
||||
# scale/position come back at the next relogin/switch.
|
||||
if hyprctl keyword monitor "$offmon,preferred,auto,auto" >/dev/null 2>&1; then
|
||||
notify-send "Display" "$offmon is back on."
|
||||
else
|
||||
notify-send "Display" "Could not enable $offmon."
|
||||
fi
|
||||
exit 0 ;;
|
||||
"Move workspace"*)
|
||||
hyprctl dispatch movecurrentworkspacetomonitor +1 >/dev/null 2>&1
|
||||
exit 0 ;;
|
||||
"Swap workspaces"*)
|
||||
hyprctl dispatch swapactiveworkspaces \
|
||||
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
|
||||
"$(printf '%s' "$mons" | jq -r '.[1].name')" >/dev/null 2>&1
|
||||
exit 0 ;;
|
||||
esac
|
||||
name=''${name%% ·*} # strip the " · WxH@R" hint
|
||||
modeBack=display
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user