diff --git a/hyprland/.config/waybar/config b/hyprland/.config/waybar/config index d183f8bb..f1400d42 100644 --- a/hyprland/.config/waybar/config +++ b/hyprland/.config/waybar/config @@ -40,7 +40,7 @@ "margin-right": 16, "modules-left": ["gamemode", "battery", "temperature", "cpu", "memory", "network"], "modules-center": [], - "modules-right": ["mpris", "pulseaudio", "backlight", "idle_inhibitor", "clock"], + "modules-right": ["mpris", "pulseaudio", "custom/output-device", "backlight", "idle_inhibitor", "clock"], "clock": { "format": "{:%a %b %d %I:%M %p}", "tooltip": false @@ -143,5 +143,14 @@ "format": "", "tooltip-format": "An rpm-ostree deployment is pending and will be applied upon the next reboot", "exec": "rpm-ostree status --json | jq -e '.deployments[0].staged'" + }, + "custom/output-device": { + "interval": 60, + "format": "{}", + "tooltip-format": "Click to switch between output devices", + "exec-on-event": true, + "exec": "$HOME/.config/waybar/scripts/output-devices.py --iconify", + "on-click": "$HOME/.config/waybar/scripts/output-devices.py --next --iconify", + "on-click-right": "$HOME/.config/waybar/scripts/output-devices.py --previous --iconify" } }] diff --git a/hyprland/.config/waybar/scripts/output-devices.py b/hyprland/.config/waybar/scripts/output-devices.py new file mode 100755 index 00000000..39256aba --- /dev/null +++ b/hyprland/.config/waybar/scripts/output-devices.py @@ -0,0 +1,138 @@ +#! /usr/bin/env python3 +import argparse +import json +import pprint +import subprocess + + +def get_sinks(): + """ + Consult pactl and get a sorted list of all sinks as dicts + """ + sinks = json.loads( + subprocess.run( + ["pactl", "-f", "json", "list", "sinks"], + capture_output=True, + text=True, + check=True, + ).stdout + ) + return sorted(sinks, key=lambda s: s["index"]) + + +def get_status(): + status = json.loads( + subprocess.run( + ["pactl", "-f", "json", "info"], + capture_output=True, + text=True, + check=True, + ).stdout + ) + return status + + +def change_sink(sink): + """ + Provided a sink object, use pactl to change over to it + """ + assert sink.get("name") + return subprocess.run(["pactl", "set-default-sink", sink.get("name")], check=True) + + +def get_current_sink(sinks, status): + """ + Given a list of all sinks and the status of the system, return the dict + for the sink that's currently the default output device + """ + return next( + ( + sink + for sink in sinks + if sink.get("name", "") == status.get("default_sink_name", "") + ), + None, + ) + + +def get_offset_sink(sinks, currentsink, offset=1): + def find_sink_by_index(sinks, index): + for sink in sinks: + if sink["index"] == index: + return sink + return None + + """ + Given a list of sinks and the current default sink, get some offset in the + list of all sinks away from it. Useful for getting next/previous sinks + """ + indexes = [s["index"] for s in sinks] + i = indexes.index(currentsink.get("index", 0)) + next_index = indexes[(i + offset) % len(indexes)] + return find_sink_by_index(sinks, next_index) + + +def get_sink_icon(sinks, currentsink): + """ + Given the list of all sinks and the current one, figure out how we should + display that as a small icon + """ + icon = { + "hdmi": "\uf26c", # fa-tv + "analog": "\uf028", # fa-volume-up + "headphones": "\uf025", # fa-headphones + "bluetooth": "\uf294", # fa-bluetooth-b + } + + def classify(sink): + name = sink.get("name", "").lower() + port = sink.get("active_port", "") + desc = sink.get("description", "").lower() + if "hdmi" in name or "hdmi" in desc: + return "hdmi" + if "headphones" in port or "headset" in desc: + return "headphones" + if "analog" in name or "lineout" in port: + return "analog" + if "bluez" in name or "bluetooth" in desc: + return "bluetooth" + return "analog" + + return icon[classify(currentsink)] + + +def main(): + parser = argparse.ArgumentParser( + description="Display and cycle through PulseAudio and PipeWire sinks" + ) + parser.add_argument( + "-i", + "--iconify", + action="store_true", + help="Print an icon instead of the full name of the sink", + ) + parser.add_argument( + "-n", "--next", action="store_true", help="Advance to the next sink" + ) + parser.add_argument( + "-p", "--previous", action="store_true", help="Advance to the previous sink" + ) + args = parser.parse_args() + + # Get pactl's status + sinks = get_sinks() + status = get_status() + currentsink = get_current_sink(sinks, status) + # Figure out our offset into the list + offset = 1 if args.next else -1 if args.previous else 0 + # Figure out where we wanna go + newsink = get_offset_sink(sinks, currentsink, offset) + change_sink(newsink) + if args.iconify: + print(get_sink_icon(sinks, newsink)) + else: + print(newsink.get("description")) + + +if __name__ == "__main__": + main() diff --git a/hyprland/.config/waybar/style.css b/hyprland/.config/waybar/style.css index 5aa65155..0d2ffbb5 100644 --- a/hyprland/.config/waybar/style.css +++ b/hyprland/.config/waybar/style.css @@ -222,3 +222,7 @@ window#waybar.fullscreen #window { color: rgba(235, 219, 178, 0.2); padding: 0 1em; } +#custom-output-device { + color: #ebdbb2; + padding: 0 1em; +}