368 lines
13 KiB
Bash
Executable File
368 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
set -u
|
|
|
|
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
readonly PANAMA_OSD_BLIP_DEFAULT="/usr/share/sounds/freedesktop/stereo/audio-volume-change.oga"
|
|
|
|
# ── Panama's own preferences ────────────────────────────────────────────────
|
|
#
|
|
# These run as keybinds, so the shell may not be up and there is no IPC to ask.
|
|
# settings.json is read straight off disk instead, and every failure -- no file
|
|
# on a fresh install, a file written before the key existed, a file that is not
|
|
# JSON at all -- means the schema default rather than a broken volume key.
|
|
settings_file() {
|
|
printf '%s/panama/settings.json\n' "${XDG_CONFIG_HOME:-$HOME/.config}"
|
|
}
|
|
|
|
# Both switches the volume keys care about, read in a single jq. A held-down
|
|
# volume key repeats about twenty times a second, and each repeat used to spawn
|
|
# jq twice -- once for the ceiling, once for the click. Prints them as
|
|
# "<overAmplification> <volumeChangeBlip>", falling back to the schema defaults
|
|
# whenever the file cannot be read or does not carry the key.
|
|
volume_settings() {
|
|
local file value over=false blip=true
|
|
file="$(settings_file)"
|
|
if [[ -r $file ]] && command -v jq >/dev/null 2>&1; then
|
|
value="$(jq -r '
|
|
def flag($key; $fallback):
|
|
if type == "object" and (.[$key] | type) == "boolean"
|
|
then (.[$key] | tostring)
|
|
else $fallback
|
|
end;
|
|
[flag("overAmplification"; "false"), flag("volumeChangeBlip"; "true")]
|
|
| join(" ")
|
|
' "$file" 2>/dev/null)" || value=""
|
|
read -r over blip <<<"$value"
|
|
[[ $over == true || $over == false ]] || over=false
|
|
[[ $blip == true || $blip == false ]] || blip=true
|
|
fi
|
|
printf '%s %s\n' "$over" "$blip"
|
|
}
|
|
|
|
# `wpctl set-volume -l` caps the *result*, and without it wpctl does not cap at
|
|
# all -- 100% is not a ceiling it enforces on its own. So the limit is passed on
|
|
# the way down too, deliberately: with over-amplification off, stepping down
|
|
# from a volume that is somehow already above 100% lands under the ceiling
|
|
# rather than merely one step lower, which is what having the switch off means.
|
|
# The microphone never gets the raised ceiling: gain past 100% on a capture
|
|
# device buys noise, not signal, and the Sound page's input slider stays 0-100
|
|
# to match.
|
|
volume_ceiling() {
|
|
if [[ $1 == true ]]; then
|
|
printf '1.5\n'
|
|
else
|
|
printf '1\n'
|
|
fi
|
|
}
|
|
|
|
# The click that says the volume moved. Backgrounded and never waited on: it is
|
|
# feedback about something that has already happened, so it must not sit between
|
|
# the key press and the OSD.
|
|
#
|
|
# Under a non-blocking lock, in the same runtime directory the DDC lock lives in,
|
|
# because key repeat would otherwise start a fresh pw-play over the top of the
|
|
# last one twenty times a second. The first press clicks; the repeats that
|
|
# arrive while it is still sounding are dropped rather than stacked.
|
|
#
|
|
# A distribution without the freedesktop sound theme has no file to play, which
|
|
# is a silent desktop rather than a broken key.
|
|
play_blip() {
|
|
local enabled="$1"
|
|
local sound="${PANAMA_OSD_BLIP_SOUND:-$PANAMA_OSD_BLIP_DEFAULT}"
|
|
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
|
|
[[ $enabled == true ]] || return 0
|
|
[[ -f $sound ]] || return 0
|
|
mkdir -p "$runtime_dir" 2>/dev/null || return 0
|
|
chmod 700 "$runtime_dir" 2>/dev/null || true
|
|
(
|
|
exec {blip_fd}>"$runtime_dir/blip.lock" || exit 0
|
|
flock -n "$blip_fd" || exit 0
|
|
pw-play "$sound" >/dev/null 2>&1
|
|
) &
|
|
}
|
|
|
|
strict_delivery() {
|
|
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
|
|
}
|
|
|
|
show_progress() {
|
|
if ! qs ipc call osd progress "$1" "$2" 100 "$3" >/dev/null 2>&1; then
|
|
strict_delivery && return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
show_message() {
|
|
if ! qs ipc call osd message "$1" "$2" >/dev/null 2>&1; then
|
|
strict_delivery && return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
volume_state() {
|
|
local target="$1" output level percent muted=false
|
|
output="$(wpctl get-volume "$target" 2>/dev/null)" || return 1
|
|
if [[ $output =~ Volume:[[:space:]]*([0-9]+([.][0-9]+)?) ]]; then
|
|
level="${BASH_REMATCH[1]}"
|
|
else
|
|
return 1
|
|
fi
|
|
[[ $output == *"[MUTED]"* ]] && muted=true
|
|
percent="$(awk -v value="$level" 'BEGIN { printf "%d", value * 100 + 0.5 }')"
|
|
printf '%s %s\n' "$percent" "$muted"
|
|
}
|
|
|
|
show_volume() {
|
|
local target="$1" kind="$2" state percent muted label
|
|
state="$(volume_state "$target")" || return 0
|
|
read -r percent muted <<<"$state"
|
|
if [[ $muted == true ]]; then
|
|
show_progress "${kind}-muted" "$percent" "Muted"
|
|
else
|
|
label="${percent}%"
|
|
show_progress "$kind" "$percent" "$label"
|
|
fi
|
|
}
|
|
|
|
adjust_volume() {
|
|
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
|
|
local over blip limit
|
|
read -r over blip <<<"$(volume_settings)"
|
|
limit="$(volume_ceiling "$over")"
|
|
case "$action" in
|
|
up) wpctl set-volume -l "$limit" "$target" "${step}%+" || return ;;
|
|
down) wpctl set-volume -l "$limit" "$target" "${step}%-" || return ;;
|
|
toggle) wpctl set-mute "$target" toggle || return ;;
|
|
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
|
|
esac
|
|
play_blip "$blip"
|
|
show_volume "$target" volume
|
|
}
|
|
|
|
adjust_microphone() {
|
|
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@"
|
|
case "$action" in
|
|
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
|
|
down) wpctl set-volume -l 1 "$target" "${step}%-" || return ;;
|
|
toggle) wpctl set-mute "$target" toggle || return ;;
|
|
*) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;;
|
|
esac
|
|
show_volume "$target" microphone
|
|
}
|
|
|
|
brightness_percent() {
|
|
local output="$1" percent
|
|
percent="$(awk -F, 'NR == 1 { value=$4; gsub(/%/, "", value); print value }' <<<"$output")"
|
|
[[ $percent =~ ^[0-9]+$ ]] || return 1
|
|
printf '%s\n' "$percent"
|
|
}
|
|
|
|
brightness_error() {
|
|
local detail="$1" label="External brightness unavailable"
|
|
if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then
|
|
label="Brightness needs permission"
|
|
fi
|
|
|
|
show_message dialog-warning-symbolic "$label" || true
|
|
if command -v notify-send >/dev/null 2>&1; then
|
|
notify-send --app-name=Panama --icon=display-brightness-symbolic \
|
|
"Brightness unavailable" "$detail" >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
|
|
discover_ddc_bus() {
|
|
local helper="$1" cache_file="$2" list_json focused selected error bus connector
|
|
|
|
command -v jq >/dev/null 2>&1 || {
|
|
brightness_error "jq is required to discover DDC/CI displays."
|
|
return 1
|
|
}
|
|
|
|
list_json="$("$helper" list 2>/dev/null)" || {
|
|
brightness_error "The external brightness helper could not inspect connected displays."
|
|
return 1
|
|
}
|
|
if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then
|
|
brightness_error "The external brightness helper returned invalid display information."
|
|
return 1
|
|
fi
|
|
|
|
error="$(jq -r '.error // empty' <<<"$list_json")"
|
|
if [[ -n $error ]]; then
|
|
brightness_error "$error"
|
|
return 1
|
|
fi
|
|
|
|
focused="$(hyprctl -j monitors 2>/dev/null \
|
|
| jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \
|
|
| head -n1)"
|
|
selected="$(jq -r --arg connector "$focused" '
|
|
([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty)
|
|
| [.bus, .connector]
|
|
| @tsv
|
|
' <<<"$list_json")"
|
|
IFS=$'\t' read -r bus connector <<<"$selected"
|
|
if [[ ! $bus =~ ^[0-9]+$ ]]; then
|
|
brightness_error "No connected monitor exposes DDC/CI brightness control."
|
|
return 1
|
|
fi
|
|
|
|
umask 077
|
|
printf '%s\t%s\n' "$bus" "$connector" >"$cache_file"
|
|
printf '%s\n' "$bus"
|
|
}
|
|
|
|
adjust_ddc_brightness() {
|
|
local action="$1" step="$2"
|
|
local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}"
|
|
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
|
|
local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock"
|
|
local bus="" connector="" current target lock_fd
|
|
|
|
[[ -x $helper ]] || {
|
|
brightness_error "The external brightness helper is not installed."
|
|
return 0
|
|
}
|
|
mkdir -p "$runtime_dir" || return 0
|
|
chmod 700 "$runtime_dir" 2>/dev/null || true
|
|
|
|
exec {lock_fd}>"$lock_file" || return 0
|
|
# DDC transactions on one I2C bus cannot safely overlap. A short wait also
|
|
# sheds an excessive key-repeat backlog instead of replaying it seconds later.
|
|
flock -w 2 "$lock_fd" || return 0
|
|
|
|
if [[ -r $cache_file ]]; then
|
|
IFS=$'\t' read -r bus connector <"$cache_file" || true
|
|
[[ $bus =~ ^[0-9]+$ ]] || bus=""
|
|
fi
|
|
|
|
if [[ -n $bus ]]; then
|
|
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
|
|
if [[ ! $current =~ ^[0-9]+$ ]]; then
|
|
: >"$cache_file"
|
|
bus=""
|
|
fi
|
|
fi
|
|
|
|
if [[ -z $bus ]]; then
|
|
bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0
|
|
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
|
|
fi
|
|
if [[ ! $current =~ ^[0-9]+$ ]]; then
|
|
brightness_error "The selected monitor stopped responding over DDC/CI."
|
|
return 0
|
|
fi
|
|
|
|
if [[ $action == up ]]; then
|
|
target=$(( current + step ))
|
|
else
|
|
target=$(( current - step ))
|
|
fi
|
|
(( target > 100 )) && target=100
|
|
(( target < 0 )) && target=0
|
|
|
|
if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then
|
|
brightness_error "The selected monitor did not accept the brightness change."
|
|
return 0
|
|
fi
|
|
show_progress brightness "$target" "${target}%"
|
|
}
|
|
|
|
adjust_brightness() {
|
|
local action="${1:-}" step="${2:-5}" output percent
|
|
[[ $step =~ ^[0-9]+$ ]] || {
|
|
printf 'Usage: panama-osd brightness up|down [step]\n' >&2
|
|
return 2
|
|
}
|
|
case "$action" in
|
|
up|down) ;;
|
|
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
|
esac
|
|
|
|
# Laptop panels expose a kernel backlight class and remain the fastest,
|
|
# most reliable path. Desktops fall through to DDC/CI monitor control.
|
|
output="$(brightnessctl -m -c backlight 2>/dev/null)" || output=""
|
|
if percent="$(brightness_percent "$output")"; then
|
|
if [[ $action == up ]]; then
|
|
brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0
|
|
else
|
|
brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0
|
|
fi
|
|
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
|
percent="$(brightness_percent "$output")" || return 0
|
|
show_progress brightness "$percent" "${percent}%"
|
|
return
|
|
fi
|
|
|
|
adjust_ddc_brightness "$action" "$step"
|
|
}
|
|
|
|
media_action() {
|
|
local action="${1:-}" kind label fallback
|
|
case "$action" in
|
|
play-pause)
|
|
playerctl play-pause || return
|
|
if [[ $(playerctl status 2>/dev/null) == "Playing" ]]; then
|
|
kind="media-play"
|
|
fallback="Playing"
|
|
else
|
|
kind="media-pause"
|
|
fallback="Paused"
|
|
fi
|
|
;;
|
|
next) playerctl next || return; kind="media-next"; fallback="Next track" ;;
|
|
previous) playerctl previous || return; kind="media-previous"; fallback="Previous track" ;;
|
|
stop) playerctl stop || return; kind="media-stop"; fallback="Stopped" ;;
|
|
*) printf 'Usage: panama-osd media play-pause|next|previous|stop\n' >&2; return 2 ;;
|
|
esac
|
|
|
|
label="$(playerctl metadata --format '{{ title }} — {{ artist }}' 2>/dev/null)"
|
|
[[ -n $label ]] || label="$fallback"
|
|
show_message "$kind" "$label"
|
|
}
|
|
|
|
# Airplane mode: one rfkill state for every radio, with an OSD saying which
|
|
# way it went. "Any radio unblocked" reads as radios-on, so toggle means
|
|
# "block everything" from a mixed state -- the direction a person reaching
|
|
# for airplane mode wants.
|
|
airplane_action() {
|
|
case "${1:-toggle}" in
|
|
toggle)
|
|
if rfkill list 2>/dev/null | grep -q 'Soft blocked: no'; then
|
|
rfkill block all 2>/dev/null \
|
|
&& show_message "airplane-mode-symbolic" "Airplane mode on"
|
|
else
|
|
rfkill unblock all 2>/dev/null \
|
|
&& show_message "network-wireless-symbolic" "Airplane mode off"
|
|
fi
|
|
;;
|
|
*)
|
|
printf 'Usage: panama-osd airplane toggle\n' >&2
|
|
return 2
|
|
;;
|
|
esac
|
|
}
|
|
|
|
case "${1:-}" in
|
|
volume) shift; adjust_volume "$@" ;;
|
|
microphone) shift; adjust_microphone "$@" ;;
|
|
brightness) shift; adjust_brightness "$@" ;;
|
|
media) shift; media_action "$@" ;;
|
|
airplane) shift; airplane_action "$@" ;;
|
|
message)
|
|
shift
|
|
kind="${1:-}"
|
|
[[ -n $kind && $# -ge 2 ]] || {
|
|
printf 'Usage: panama-osd message ICON LABEL\n' >&2
|
|
exit 2
|
|
}
|
|
shift
|
|
show_message "$kind" "$*"
|
|
;;
|
|
*)
|
|
printf 'Usage: panama-osd volume|microphone|brightness|media|airplane|message ACTION [value]\n' >&2
|
|
exit 2
|
|
;;
|
|
esac
|