Files
dotfiles/.config/i3/volume
2021-08-17 14:11:27 +02:00

1274 lines
37 KiB
Bash
Executable File

#!/bin/bash
#
# i3-volume
#
# Volume control and volume notifications.
#
# Dependencies:
# awk (POSIX compatible)
# pulseaudio-utils - if using PulseAudio
# alsa-utils - if using amixer
#
# Copyright (c) 2016 Beau Hastings. All rights reserved.
# License: GNU General Public License v2
#
# Author: Beau Hastings <beau@saweet.net>
# URL: https://github.com/hastinbe/i3-volume
# Wiki: https://github.com/hastinbe/i3-volume/wiki/
define_helpers() {
empty() {
[[ -z $1 ]]
}
not_empty() {
[[ -n $1 ]]
}
isset() {
[[ -v $1 ]]
}
command_exists() {
command -v "$1" >/dev/null 2>&1;
}
error() {
echo "$COLOR_RED$*$COLOR_RESET"
}
has_color() {
(( $(tput colors 2>/dev/null || echo 0) >= 8 )) && [ -t 1 ]
}
# Converts milliseconds to seconds with rounding up
#
# Arguments:
# milliseconds (integer) An integer in milliseconds
ms_to_secs() {
echo $(( ($1 + (1000 - 1)) / 1000 ))
}
is_command_hookable() {
! [[ ${POST_HOOK_EXEMPT_COMMANDS[*]} =~ $1 ]]
}
has_capability() {
[[ "${NOTIFY_CAPS[*]}" =~ $1 ]]
}
max() {
echo $(( $1 > $2 ? $1 : $2 ))
}
}
define_notify() {
# Display a notification indicating muted or current volume.
notify_volume() {
local -r vol=$(get_volume)
local icon
if is_muted; then
text="Volume muted"
if $USE_FULLCOLOR_ICONS; then
icon=${ICONS[0]}
else
icon=${ICONS_SYMBOLIC[0]}
fi
else
printf -v text "Volume %3s%%" "$vol"
icon=$(get_volume_icon "$vol")
if $SHOW_VOLUME_PROGRESS; then
local -r progress=$(progress_bar "$vol")
text="$text $progress"
fi
fi
case "$NOTIFICATION_METHOD" in
xosd ) notify_volume_xosd "$vol" "$text" ;;
herbe ) notify_volume_herbe "$text" ;;
volnoti ) notify_volume_volnoti "$vol" ;;
kosd ) notify_volume_kosd "$vol" ;;
dunst ) notify_volume_libnotify "$vol" "$icon" "$text" ;;
notify-osd ) notify_volume_libnotify "$vol" "$icon" "$text" ;;
libnotify ) notify_volume_libnotify "$vol" "$icon" "$text" ;;
haskell-notification-daemon) notify_volume_libnotify "$vol" "$icon" "$text" ;;
* ) notify_volume_libnotify "$vol" "$icon" "$text" ;;
esac
}
list_notification_methods() {
awk -W posix 'match($0,/ notify_volume_([[:alnum:]]+)/) {print substr($0, 19, RLENGTH-18)}' "${BASH_SOURCE[0]}" || exit "$EX_USAGE"
exit "$EX_OK"
}
setup_notification_icons() {
if not_empty "$SYMBOLIC_ICON_SUFFIX"; then
apply_symbolic_icon_suffix
fi
}
show_volume_notification() {
$DISPLAY_NOTIFICATIONS || return
if empty "$NOTIFICATION_METHOD"; then
load_notify_server_info
NOTIFICATION_METHOD=$NOTIFY_SERVER
fi
setup_notification_icons
notify_volume
}
# Loads notification system information via DBus
load_notify_server_info() {
command_exists dbus-send || return
IFS=$'\t' read -r NOTIFY_SERVER _ _ _ < <(dbus-send --print-reply --dest=org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications.GetServerInformation | awk 'BEGIN { ORS="\t" }; match($0, /^ string ".*"/) {print substr($0, RSTART+11, RLENGTH-12)}')
}
# Load notification system capabilities via DBus
load_notify_server_caps() {
command_exists dbus-send || return
IFS= read -r -d '' -a NOTIFY_CAPS < <(dbus-send --print-reply=literal --dest="${DBUS_NAME}" "${DBUS_PATH}" "${DBUS_IFAC_FDN}.GetCapabilities" | awk 'RS=" " { if (NR > 2) print $1 }')
}
# Send notifcation for libnotify-compatible notification daemons.
#
# Arguments:
# Volume (integer) An integer indicating the volume.
# Icon (string) Icon to display.
# Text (string) Notification text.
notify_volume_libnotify() {
local -r vol=$1
local -r icon=$2
local -r text=${*:3}
local -a args=(
-t "$EXPIRES"
)
local -a hints=(
# Replaces previous notification in some notification servers
string:synchronous:volume
# Replaces previous notification in NotifyOSD
string:x-canonical-private-synchronous:volume
)
# If we're not drawing our own progress bar, allow the notification daemon to draw its own (if supported)
if ! $SHOW_VOLUME_PROGRESS; then
hints+=(int:value:"$vol")
fi
(( ${#NOTIFY_CAPS[@]} < 1 )) && load_notify_server_caps
if has_capability icon-static; then
args+=(-i "$icon")
# haskell-notification-daemon (aka deadd-notification-center) does not support -i|--icon
hints+=(string:image-path:"$icon")
fi
if $PLAY_SOUND && has_capability sound; then
hints+=(string:sound-name:audio-volume-change)
fi
if ! isset NO_NOTIFY_COLOR && [[ $NOTIFICATION_METHOD == "dunst" ]]; then
if is_muted; then
hints+=(string:fgcolor:"$COLOR_MUTED")
else
hints+=(string:fgcolor:"$(volume_color "$vol")")
fi
fi
if $USE_DUNSTIFY; then
args+=(-r 1000)
# Transient notifications will bypass the idle_threshold setting.
# Should be boolean, but Notify-OSD doesn't support boolean yet. Dunst checks
# for int and bool with transient so lets play nice with both servers.
hints+=(int:transient:1)
read -ra hints <<< "${hints[@]/#/-h }"
"${DUNSTIFY_PATH:+${DUNSTIFY_PATH%/}/}dunstify" "${hints[@]}" "${args[@]}" "$text"
elif isset USE_NOTIFY_SEND_PY; then
# Replaces previous notification, but leaves itself running in the bg to work
args+=(--replaces-process volume)
# By-pass the server's persistence capability, if it should exist
hints+=(boolean:transient:true)
"${NOTIFY_SEND_PATH:+${NOTIFY_SEND_PATH%/}/}notify-send.py" --hint "${hints[@]}" "${args[@]}" "$text" &
else
read -ra hints <<< "${hints[@]/#/-h }"
"${NOTIFY_SEND_PATH:+${NOTIFY_SEND_PATH%/}/}notify-send" "${hints[@]}" "${args[@]}" "$text"
fi
}
# Send notification to XOSD.
#
# Arguments:
# Volume (integer) An integer indicating the volume.
# Text (string) Notification text.
notify_volume_xosd() {
local -r vol=$1
local -r text=${*:2}
local -r delay=$(ms_to_secs "$EXPIRES")
local percentage
if is_muted; then
color=$COLOR_MUTED
percentage=0
else
color=$(volume_color "$vol")
percentage=$vol
fi
"${XOSD_PATH:+${XOSD_PATH%/}/}osd_cat" --align center -b percentage -P "$percentage" -d "$delay" -p top -A center -c "$color" -T "$text" -O 2 -u "$COLOR_XOSD_OUTLINE" & disown
}
# Send notification to herbe.
#
# Arguments:
# Text (string) Notification text.
#
# Note: a patch with a notify-send script for herbe, not in the current version at this
# time but would make this irrelevant. See https://github.com/dudik/herbe/pull/10
notify_volume_herbe() {
local -r text=$*
# Dismiss existing/pending notifications to prevent queuing
pkill -SIGUSR1 herbe
"${HERBE_PATH:+${HERBE_PATH%/}/}herbe" "$text" & disown
}
# Send notification to volnoti.
#
# Arguments:
# Volume (integer) An integer indicating the volume.
notify_volume_volnoti() {
local -r vol=$1
if is_muted; then
"${VOLNOTI_PATH:+${VOLNOTI_PATH%/}/}volnoti-show" -m "$vol"
else
"${VOLNOTI_PATH:+${VOLNOTI_PATH%/}/}volnoti-show" "$vol"
fi
}
# Send notification to KOSD.
#
# Arguments:
# Volume (integer) An integer indicating the volume.
notify_volume_kosd() {
local -r vol=$1
if is_muted; then
qdbus org.kde.kded /modules/kosd showVolume "$vol" 1
else
qdbus org.kde.kded /modules/kosd showVolume "$vol" 0
fi
}
}
define_output_formats() {
# Outputs the current volume in the default format.
output_volume_default() {
if is_muted; then
echo MUTE
else
echo "$(get_volume)%"
fi
}
# Outputs the current volume using a custom format string.
#
# Format options:
# %v = volume percentage or "MUTE" when muted
# %s = sink name (PulseAudio only)
# %c = card (alsamixer only)
# %m = mixer (alsamixer only)
# %p = volume progress bar
# %i = volume icon
output_volume_custom() {
local -r format=$*
local -r vol=$(get_volume)
local string
if is_muted; then
string=${format//\%v/MUTE}
else
string=${format//\%v/$vol%}
fi
string=${string//\%s/$SINK}
string=${string//\%c/$CARD}
string=${string//\%m/$MIXER}
string=${string//\%p/$(progress_bar "$vol")}
string=${string//\%i/$(get_volume_emoji "$vol")}
echo -ne "$string"
}
# Outputs the current volume for i3blocks.
output_volume_i3blocks() {
local short_text
local full_text
if is_muted; then
short_text="<span color=\"$COLOR_MUTED\">MUTE</span>\n"
full_text="<span color=\"$COLOR_MUTED\">MUTE</span>\n"
else
local -r vol=$(get_volume)
local -r color=$(volume_color "$vol")
short_text="<span color=\"$color\">${vol}%</span>\n"
full_text="<span color=\"$color\">${vol}%</span>\n"
if isset MAX_VOLUME && (( vol > MAX_VOLUME )); then
EXITCODE=$EX_URGENT
fi
fi
echo -ne "$full_text$short_text"
}
# Outputs the current volume for xob.
output_volume_xob() {
local -ir vol=$(get_volume)
if is_muted; then
echo "${vol}!"
else
echo "$vol"
fi
}
}
define_commands() {
# Increase volume relative to current volume.
#
# Arguments:
# Step (integer) Percentage to increase by.
# Max Volume (optional) (integer|percentage) Maximum volume limit.
increase_volume() {
local step=${1:?$(error 'Step is required')}
local -r max_volume=$2
if not_empty "$max_volume"; then
local -r vol=$(get_volume)
if (( vol + step > max_volume )); then
# Instead of doing nothing, step to max_volume
step=$( max "0" "$(( max_volume - vol ))" )
fi
fi
if $USE_AMIXER; then
amixer_increase_volume "$CARD" "$step"
else
pa_increase_volume "$SINK" "$step"
fi
}
# Decrease volume relative to current volume.
#
# Arguments:
# Step (integer) Percentage to decrease by.
decrease_volume() {
local -r step=${1:?$(error 'Step is required')}
if $USE_AMIXER; then
amixer_decrease_volume "$CARD" "$step"
else
pa_decrease_volume "$SINK" "$step"
fi
}
# Set volume.
#
# Arguments:
# Volume (integer|linear factor|percentage|decibel)
# Max Volume (optional) (integer|percentage) Maximum volume limit.
set_volume() {
local -r vol=${1:?$(error 'Volume is required')}
local -r max_volume=$2
if not_empty "$max_volume" && (( vol > max_volume )); then
return
fi
if $USE_AMIXER; then
amixer_set_volume "${vol}%" "$CARD"
else
pa_set_volume "$SINK" "${vol}%"
fi
}
toggle_mute() {
if $USE_AMIXER; then
amixer_toggle_mute "$CARD"
else
pa_toggle_mute "$SINK"
fi
}
# Outputs the current volume.
#
# Arguments
# Output method (string) Method to use to output volume.
output_volume() {
local -r for=${1:?$(error 'Output method is required')}
case "$for" in
i3blocks ) output_volume_i3blocks ;;
xob ) output_volume_xob ;;
default ) output_volume_default ;;
* ) output_volume_custom "$*" ;;
esac
}
list_output_formats() {
awk -W posix 'match($0,/ output_volume_([[:alnum:]]+)/) {print substr($0, 19, RLENGTH-18)}' "${BASH_SOURCE[0]}" || exit "$EX_USAGE"
exit "$EX_OK"
}
usage() {
cat <<- EOF 1>&2
${COLOR_YELLOW}Usage:${COLOR_RESET} $0 [<options>] <command> [<args>]
Control volume and related notifications.
${COLOR_YELLOW}Commands:${COLOR_RESET}
${COLOR_GREEN}up <value>${COLOR_RESET} increase volume
${COLOR_GREEN}down <value>${COLOR_RESET} decrease volume
${COLOR_GREEN}set <value>${COLOR_RESET} set volume
${COLOR_GREEN}mute${COLOR_RESET} toggle mute
${COLOR_GREEN}listen${COLOR_RESET} listen for changes to a PulseAudio sink
${COLOR_GREEN}output <format>${COLOR_RESET} output volume in a supported format
custom format substitutions:
%v = volume
%s = sink name (PulseAudio only)
%c = card (alsamixer only)
%m = mixer (alsamixer only)
%p = volume progress bar
%i = volume icon/emoji
examples:
"Volume is %v" = Volume is 50%
"%i %v %p \n" = 奔 50% ██████████
${COLOR_GREEN}outputs${COLOR_RESET} show available output formats
${COLOR_GREEN}notifications${COLOR_RESET} show available notification methods
${COLOR_GREEN}help${COLOR_RESET} display help
${COLOR_YELLOW}Options:${COLOR_RESET}
${COLOR_GREEN}-a${COLOR_RESET} use amixer
${COLOR_GREEN}-n${COLOR_RESET} enable notifications
${COLOR_GREEN}-C${COLOR_RESET} use libcanberra for playing event sounds
${COLOR_GREEN}-P${COLOR_RESET} play sound for volume changes
${COLOR_GREEN}-j <muted,high,low,medium>${COLOR_RESET} specify custom volume emojis as a comma separated list
${COLOR_GREEN}-t <process_name>${COLOR_RESET} process name of status bar (${COLOR_MAGENTA}requires -u${COLOR_RESET})
${COLOR_GREEN}-u <signal>${COLOR_RESET} signal to update status bar (${COLOR_MAGENTA}requires -t${COLOR_RESET})
${COLOR_GREEN}-x <value>${COLOR_RESET} maximum volume
${COLOR_GREEN}-X <value>${COLOR_RESET} maximum amplification; if supported (${COLOR_MAGENTA}default: 2${COLOR_RESET})
${COLOR_GREEN}-h${COLOR_RESET} display help
${COLOR_YELLOW}amixer Options:${COLOR_RESET}
${COLOR_GREEN}-c <card>${COLOR_RESET} card number to control
${COLOR_GREEN}-m <mixer>${COLOR_RESET} set mixer (${COLOR_MAGENTA}default: Master${COLOR_RESET})
${COLOR_YELLOW}PulseAudio Options:${COLOR_RESET}
${COLOR_GREEN}-s <sink>${COLOR_RESET} symbolic name of sink
${COLOR_YELLOW}Notification Options:${COLOR_RESET}
${COLOR_GREEN}-N <method>${COLOR_RESET} notification method (${COLOR_MAGENTA}default: libnotify${COLOR_RESET})
${COLOR_GREEN}-p${COLOR_RESET} enable progress bar
${COLOR_GREEN}-e <expires>${COLOR_RESET} expiration time of notifications in ms
${COLOR_GREEN}-l${COLOR_RESET} use fullcolor instead of symbolic icons
${COLOR_GREEN}-S <suffix>${COLOR_RESET} append suffix to symbolic icon names
${COLOR_GREEN}-y${COLOR_RESET} use dunstify (${COLOR_MAGENTA}default: notify-send${COLOR_RESET})
${COLOR_YELLOW}Environment Variables:${COLOR_RESET}
${COLOR_CYAN}XOSD_PATH${COLOR_RESET} path to osd_cat
${COLOR_CYAN}HERBE_PATH${COLOR_RESET} path to herbe
${COLOR_CYAN}VOLNOTI_PATH${COLOR_RESET} path to volnoti-show
${COLOR_CYAN}DUNSTIFY_PATH${COLOR_RESET} path to dunstify
${COLOR_CYAN}CANBERRA_PATH${COLOR_RESET} path to canberra-gtk-play
${COLOR_CYAN}NOTIFY_SEND_PATH${COLOR_RESET} path to notify-send or notify-send.py
${COLOR_CYAN}USE_NOTIFY_SEND_PY${COLOR_RESET} flag to use notify-send.py instead of notify-send
${COLOR_CYAN}NO_NOTIFY_COLOR${COLOR_RESET} flag to disable colors in notifications
EOF
exit "$EX_USAGE"
}
}
# Get the volume as a percentage.
get_volume() {
if $USE_AMIXER; then
amixer_get_volume "$CARD" "$MIXER"
else
pa_get_volume "$SINK"
fi
}
is_muted() {
if $USE_AMIXER; then
amixer_is_muted "$CARD"
return $?
else
pa_is_muted "$SINK"
return $?
fi
}
# Gets an icon for the provided volume.
#
# Arguments:
# Volume (integer) An integer indicating the volume.
#
# Returns:
# The volume icon name.
get_volume_icon() {
local -r vol=${1:?$(error 'Volume is required')}
local icon
if $USE_FULLCOLOR_ICONS; then
if (( vol >= 70 )); then icon=${ICONS[1]}
elif (( vol >= 40 )); then icon=${ICONS[3]}
elif (( vol > 0 )); then icon=${ICONS[2]}
else icon=${ICONS[2]}
fi
else
# Get overamplified icon if available, otherwise default to high volume icon
if (( vol > 100 )); then icon=${ICONS_SYMBOLIC[4]:-${ICONS_SYMBOLIC[1]}}
elif (( vol >= 70 )); then icon=${ICONS_SYMBOLIC[1]}
elif (( vol >= 40 )); then icon=${ICONS_SYMBOLIC[3]}
elif (( vol > 0 )); then icon=${ICONS_SYMBOLIC[2]}
else icon=${ICONS_SYMBOLIC[2]}
fi
fi
echo "$icon"
}
# Gets an emoji for the provided volume.
#
# Arguments:
# Volume (integer) An integer indicating the volume.
#
# Returns:
# The volume emoji.
get_volume_emoji() {
local -r vol=${1:?$(error 'Volume is required')}
local icon
if is_muted; then
icon=${ICONS_EMOJI[0]}
else
if (( vol >= 70 )); then icon=${ICONS_EMOJI[1]}
elif (( vol >= 40 )); then icon=${ICONS_EMOJI[3]}
elif (( vol > 0 )); then icon=${ICONS_EMOJI[2]}
else icon=${ICONS_EMOJI[2]}
fi
fi
echo "$icon"
}
# Updates the status line.
#
# Arguments:
# signal (string) The signal used to update the status line.
# proc (string) The name of the status line process.
update_statusline() {
local -r signal=${1:?$(error 'Signal is required')}
local -r proc=${2:?$(error 'Process name is required')}
pkill "-$signal" "$proc"
}
# Generates a progress bar for the provided value.
#
# Arguments:
# Percentage (integer) Percentage of progress.
# Maximum (integer) Maximum percentage. (default: 100)
# Divisor (integer) For calculating the ratio of blocks to progress (default: 5)
#
# Returns:
# The progress bar.
progress_bar() {
local -r percent=${1:?$(error 'Percentage is required')}
local -r max_percent=${2:-100}
local -r divisor=${3:-5}
local -r progress=$(( (percent > max_percent ? max_percent : percent) / divisor ))
printf -v bar "%*s" $progress ""
echo "${bar// /█}"
}
apply_symbolic_icon_suffix() {
for i in "${!ICONS_SYMBOLIC[@]}"; do
ICONS_SYMBOLIC[$i]="${ICONS_SYMBOLIC[$i]}${SYMBOLIC_ICON_SUFFIX}"
done
}
# Get color for the given volume
#
# Arguments:
# $1 - The volume
volume_color() {
local -ir vol=${1:?$(error 'A volume is required')}
if $USE_AMIXER; then
amixer_volume_color "$vol"
else
pa_volume_color "$vol"
fi
}
# Updates the status bars
#
# Returns
# 0 when no problem occurred
# 1 when one $of signal or $statusline are set but not both
update_statusbar() {
if not_empty "$SIGNAL"; then
if empty "$STATUSLINE"; then
return 1
fi
update_statusline "$SIGNAL" "$STATUSLINE"
else
if not_empty "$STATUSLINE"; then
return 1
fi
fi
return 0
}
setup_audio() {
if $USE_AMIXER; then
setup_amixer
else
setup_pulseaudio
fi
}
# All PulseAudio functions are defined here
define_pulseaudio_functions() {
# Executes `pactl list sinks` or return its output if called previously
pa_list_sinks() {
if $OPT_LISTEN || empty "$PA_LIST_SINKS"; then
PA_LIST_SINKS=$(pactl list sinks)
fi
echo "$PA_LIST_SINKS"
}
pa_invalidate_cache() {
unset PA_LIST_SINKS
}
pa_default_sink_name() {
pactl info | awk -F': ' '/^Default Sink: /{print $2}'
}
# Get the index of a sink name.
#
# Arguments
# Sink name (string) Symbolic name of sink.
pa_get_sink_index() {
local -r sink=${1:?$(error 'Sink name is required')}
pa_list_sinks | \
awk -W posix '/^Sink #/{gsub("#", ""); idx = $2}
/^[ \t]+Name: / {insink = $2 == "'"$sink"'"; if (insink) { print idx }; exit}'
}
# Get the volume as a percentage.
#
# Arguments
# Sink name (string) Symbolic name of sink.
pa_get_volume() {
local -r sink=${1:?$(error 'Sink name is required')}
pa_list_sinks | \
awk -W posix '/^[ \t]+Name: / {insink = $2 == "'"$sink"'"}
/^[ \t]+Volume: / && insink {gsub("%,?", ""); print $5; exit}'
}
# Get the max volume as a percentage.
#
# Arguments
# Sink name (string) Symbolic name of sink.
pa_get_base_volume() {
local -r sink=${1:?$(error 'Sink name is required')}
pa_list_sinks | \
awk -W posix '/^[ \t]+Name: / {insink = $2 == "'"$sink"'"}
/^[ \t]+Base Volume: / && insink {gsub("%", ""); print $5; exit}'
}
# Increase volume relative to current volume using pulseaudio.
#
# Arguments:
# Sink name (string) Symbolic name of sink.
# Step (integer) Percentage to increase by.
pa_increase_volume() {
local -r sink=$1
local -r step=${2:=-5}
pa_set_volume "$sink" "+${step}%"
}
# Decrease volume relative to current volume using pulseaudio.
#
# Arguments:
# Sink name (string) Symbolic name of sink.
# Step (integer|percentage) Percentage to decrease by.
pa_decrease_volume() {
local -r sink=$1
local -r step=${2:=-5}
pa_set_volume "$sink" "-${step}%"
}
# Set volume using pulseaudio.
#
# Arguments:
# Sink name (string) Symbolic name of sink.
# Volume (integer|linear factor|percentage|decibel)
pa_set_volume() {
local -r sink=${1:?$(error 'Sink name is required')}
local -r vol=${2:?$(error 'Volume is required')}
pa_invalidate_cache
pactl set-sink-volume "$sink" "$vol"
}
# Toggle mute using pulseaudio.
#
# Arguments:
# Sink name (string) Symbolic name of sink.
pa_toggle_mute() {
local -r sink=${1:?$(error 'Sink name is required')}
pa_invalidate_cache
pactl set-sink-mute "$sink" toggle
}
# Check if sink is muted.
#
# Arguments:
# Sink name (string) Symbolic name of sink.
#
# Returns:
# 0 when true, 1 when false.
pa_is_muted() {
local -r sink=${1:?$(error 'Sink name is required')}
pa_list_sinks | \
awk -W posix '/^[ \t]+Name: / {insink = $2 == "'"$sink"'"}
/^[ \t]+Mute: / && insink && $2 ~ /^yes$/ { exitcode=1 }; END { exit !exitcode }'
}
# Get the flags of the PulseAudio sink.
#
# Arguments
# Sink name (string) Symbolic name of sink.
pa_get_sink_flags() {
local -r sink=${1:?$(error 'Sink name is required')}
pa_list_sinks | \
awk -W posix '/^[ \t]+Name: / {insink = $2 == "'"$sink"'"}
/^[ \t]+Flags: / && insink { for(i = 2; i <= NF; ++i) printf $i FS; exit}'
}
# Get color for the given volume for PulseAudio
#
# Arguments:
# $1 - The volume
pa_volume_color() {
local -ir vol=${1:?$(error 'A volume is required')}
if (( vol >= PA_VOLUME_MUTED && vol < PA_BASE_VOLUME )); then
echo "$COLOR_MUTED_TO_BASE"
elif (( vol >= PA_BASE_VOLUME && vol <= PA_VOLUME_NORM )); then
echo "$COLOR_BASE_TO_NORM"
elif (( vol > PA_VOLUME_NORM && vol <= MAX_VOLUME )); then
echo "$COLOR_NORM_TO_MAX"
else
echo "$COLOR_OTHER"
fi
}
# Listens for PulseAudio events
#
# Arguments:
# Output (optional) (string) An output mode. When set, outputs volume in the output mode format.
listen() {
local -r output=$*
local -r index=$(pa_get_sink_index "$SINK")
# Output volume so statusbars have something to display before any event occurs
not_empty "$output" && output_volume "$output"
while IFS= read -r; do
show_volume_notification
update_statusbar
play_volume_changed
not_empty "$output" && output_volume "$output"
done < <(pactl subscribe | stdbuf -oL grep -e "Event 'change' on sink #$index")
}
# Play a sound file.
#
# Arguments:
# Sound file (string)
pa_play() {
local -r file=$1
paplay -d "$SINK" "$file" &
}
}
# Register PulseAudio related functions and settings
setup_pulseaudio() {
define_pulseaudio_functions
PA_LIST_SINKS=$(pactl list sinks) || exit 1
if empty "$SINK"; then
SINK="$(pa_default_sink_name)"
fi
# Determine a max volume when it's not specified
if ! isset MAX_VOLUME; then
read -ra SINK_FLAGS < <(pa_get_sink_flags "$SINK")
PA_BASE_VOLUME=$(pa_get_base_volume "$SINK")
# Does the sink support digital (software) amplification?
if [[ "${SINK_FLAGS[*]}" =~ "DECIBEL_VOLUME" ]]; then
MAX_VOLUME=$((PA_VOLUME_NORM * MAX_AMPLIFICATION))
else
MAX_VOLUME=$PA_VOLUME_NORM
fi
fi
}
# All amixer functions are defined here
define_amixer_functions() {
# Get the volume as a percentage.
#
# Arguments
# Card (integer) Card number to control.
# Mixer (string) Name of the mixer.
amixer_get_volume() {
local -r card=$1
local -r mixer=${2:-Master}
amixer ${card:+-c "$card" --} sget "$mixer" | \
awk -W posix -F'[][]' '/dB/ { gsub("%", ""); print $2 }'
}
# Increase volume relative to current volume using amixer.
#
# Arguments:
# Card (integer) Card number to control.
# Step (integer) Percentage to increase by.
amixer_increase_volume() {
local -r card=$1
local -r step=${2:=-5}
amixer_set_volume "${step}%+" "$card"
}
# Decrease volume relative to current volume using amixer.
#
# Arguments:
# Card (integer) Card number to control.
# Step (integer) Percentage to decrease by.
amixer_decrease_volume() {
local -r card=$1
local -r step=${2:=-5}
amixer_set_volume "${step}%-" "$card"
}
# Set volume using amixer.
#
# Arguments:
# Volume (integer|linear factor|percentage|decibel)
# Card (optional) (integer) Card number to control.
amixer_set_volume() {
local -r vol=${1:?$(error 'Volume is required')}
local -r card=$2
amixer -q ${card:+-c "$card" --} set "$MIXER" "$vol"
}
# Toggle mute using amixer.
#
# Arguments:
# Card (integer) Card number to control.
amixer_toggle_mute() {
local -r card=$1
amixer -q ${card:+-c "$card" --} set "$MIXER" toggle
}
# Check if card is muted.
#
# Arguments:
# Card (optional) (integer) Card number to control.
#
# Returns:
# 0 when true, 1 when false.
amixer_is_muted() {
local -r card=$1
amixer ${card:+-c "$card" --} sget "$MIXER" | \
awk -W posix -F'[][]' '/dB/ && $6 ~ /^off$/ { exitcode=1 }; END { exit !exitcode }'
}
# Get color for the given volume for amixer
#
# Arguments:
# $1 - The volume
amixer_volume_color() {
local -ir vol=${1:?$(error 'A volume is required')}
if (( vol >= 0 && vol < 100 )); then
echo "$COLOR_MUTED_TO_BASE"
elif (( vol == 100 )); then
echo "$COLOR_BASE_TO_NORM"
elif (( vol > 100 && vol <= MAX_VOLUME )); then
echo "$COLOR_NORM_TO_MAX"
else
echo "$COLOR_OTHER"
fi
}
# Play a sound file.
#
# Arguments:
# Sound file (string)
amixer_play() {
local -r file=$1
aplay -q "$file" &
}
}
# Register amixer related functions and settings
setup_amixer() {
define_amixer_functions
}
setup_color() {
if has_color; then
COLOR_RESET=$'\033[0m'
COLOR_RED=$'\033[0;31m'
COLOR_GREEN=$'\033[0;32m'
COLOR_YELLOW=$'\033[0;33m'
COLOR_MAGENTA=$'\033[0;35m'
COLOR_CYAN=$'\033[0;36m'
fi
}
# Rearrange all options to place flags first
# Author: greycat
# URL: https://mywiki.wooledge.org/ComplexOptionParsing
arrange_opts() {
local flags args optstr=$1
shift
while (($#)); do
case $1 in
--)
args+=("$@")
break;
;;
-*)
flags+=("$1")
if [[ $optstr == *"${1: -1}:"* ]]; then
flags+=("$2")
shift
fi
;;
*)
args+=("$1")
;;
esac
shift
done
OPTARR=("${flags[@]}" "${args[@]}")
}
parse_opts() {
local optstring=:ac:Ce:hj:lm:nN:pPs:S:t:u:x:X:y
arrange_opts "$optstring" "$@"
set -- "${OPTARR[@]}"
OPTIND=1
while getopts "$optstring" opt; do
case "$opt" in
a ) USE_AMIXER=true ;;
c ) CARD=$OPTARG ;;
C ) USE_CANBERRA=true ;;
e ) EXPIRES=$OPTARG ;;
j ) IFS=, read -ra ICONS_EMOJI <<< "$OPTARG" ;;
l ) USE_FULLCOLOR_ICONS=true ;;
m ) MIXER=${OPTARG@Q} ;;
n ) DISPLAY_NOTIFICATIONS=true ;;
N ) NOTIFICATION_METHOD=$OPTARG ;;
p ) SHOW_VOLUME_PROGRESS=true ;;
P ) PLAY_SOUND=true ;;
s ) SINK=$OPTARG ;;
S ) SYMBOLIC_ICON_SUFFIX=$OPTARG ;;
t ) STATUSLINE=$OPTARG ;;
u ) SIGNAL=$OPTARG ;;
x ) MAX_VOLUME=$OPTARG ;;
X ) MAX_AMPLIFICATION=$OPTARG ;;
y ) USE_DUNSTIFY=true ;;
h | *) usage ;;
esac
done
read -ra CMDARGS <<< "${OPTARR[@]:$((OPTIND-1))}"
}
exec_command() {
IFS=' ' read -ra ARGS <<< "$1"
set -- "${ARGS[@]}"
COMMAND=${1:?$(error 'A command is required')}
shift
case "$COMMAND" in
up|raise|increase)
case "$#" in 1) ;; *) usage ;; esac
increase_volume "$1" "$MAX_VOLUME"
;;
down|lower|decrease)
case "$#" in 1) ;; *) usage ;; esac
decrease_volume "$1"
;;
set)
case "$#" in 1) ;; *) usage ;; esac
case "$1" in
+*) increase_volume "${1:1}" "$MAX_VOLUME" ;;
-*) decrease_volume "${1:1}" ;;
*) set_volume "$1" "$MAX_VOLUME" ;;
esac
;;
mute)
toggle_mute
;;
listen)
listen "$*"
;;
output)
case "$#" in 0) usage ;; esac
output_volume "$*"
exit "${EXITCODE:-$EX_OK}"
;;
outputs)
list_output_formats
;;
notifications)
list_notification_methods
;;
*)
usage
;;
esac
}
play_volume_changed() {
$PLAY_SOUND || return
# Sound can be handled by the notification method
if $DISPLAY_NOTIFICATIONS && has_capability sound; then
return
fi
if $USE_CANBERRA; then
ca_play "$SOUND_VOLUME_CHANGED" "Volume Changed"
else
if $USE_AMIXER; then
amixer_play "$SOUND_VOLUME_CHANGED"
else
pa_play "$SOUND_VOLUME_CHANGED"
fi
fi
}
ca_play() {
local -r file=$1 desc=$2
if [[ -f $file ]]; then
"${CANBERRA_PATH:+${CANBERRA_PATH%/}/}canberra-gtk-play" -f "$file" -d "$desc"
else
"${CANBERRA_PATH:+${CANBERRA_PATH%/}/}canberra-gtk-play" -i "audio-volume-change" -d "$desc"
fi
}
post_command_hook() {
if is_command_hookable "$COMMAND"; then
show_volume_notification
play_volume_changed
update_statusbar || usage
fi
}
main() {
# Getopt parsing variables
declare OPTIND
declare -a OPTARR CMDARGS
###########################################################
# Non-command line option variables
###########################################################
# Commands which will not use post_command_hook(), usually because
# they handle notifications and/or statusbar updates manually
declare -a POST_HOOK_EXEMPT_COMMANDS=(
listen
)
# Exit codes
declare -ir \
EX_OK=0 \
EX_URGENT=33 \
EX_USAGE=64
# Main program exit code
declare -i EXITCODE=$EX_OK
# Standard notification icons. Usually full color
# Note: order matters; muted, high, low, medium, and optionally overamplified
declare -a ICONS=(
audio-volume-muted
audio-volume-high
audio-volume-low
audio-volume-medium
)
# Symbolic notification icons. Usually low color or monochrome
# Note: order matters; muted, high, low, medium, and optionally overamplified
declare -a ICONS_SYMBOLIC=(
audio-volume-muted-symbolic
audio-volume-high-symbolic
audio-volume-low-symbolic
audio-volume-medium-symbolic
## Only exists in some icon sets
# audio-volume-overamplified-symbolic
)
# Emoji-based icons.
declare -a ICONS_EMOJI=(
奔
)
# Volume changed sound.
declare SOUND_VOLUME_CHANGED=${SOUND_VOLUME_CHANGED:-/usr/share/sounds/freedesktop/stereo/audio-volume-change.oga}
# DBUS constants
declare -r \
DBUS_NAME=org.freedesktop.Notifications \
DBUS_PATH=/org/freedesktop/Notifications \
DBUS_IFAC_FDN=org.freedesktop.Notifications
# Notification server information
declare \
NOTIFY_SERVER
# NOTIFY_VENDOR \
# NOTIFY_VERSION \
# NOTIFY_SPEC_VERSION
# Notification capabilities
declare -a NOTIFY_CAPS=()
# PulseAudio sink flags
declare -a SINK_FLAGS=()
# PulseAudio volume variables and constants.
# Note: unlike in PA, PA_VOLUME_* here are percentages instead of integers
declare -i PA_BASE_VOLUME=100
declare -ir \
PA_VOLUME_NORM=100 \
PA_VOLUME_MUTED=0
# Cached output of `pactl list sinks`; so we don't have to call it each time we need it
declare PA_LIST_SINKS
# Output volume colors
declare -r \
COLOR_MUTED=${COLOR_MUTED:-#FFFF00} \
COLOR_MUTED_TO_BASE=${COLOR_MUTED_TO_BASE:-#00FF00} \
COLOR_BASE_TO_NORM=${COLOR_BASE_TO_NORM:-#FFFF00} \
COLOR_NORM_TO_MAX=${COLOR_NORM_TO_MAX:-#FF0000} \
COLOR_OTHER=${COLOR_OTHER:-#FFFFFF} \
COLOR_XOSD_OUTLINE=${COLOR_XOSD_OUTLINE:-#222222}
declare \
COLOR_RESET \
COLOR_RED \
COLOR_GREEN \
COLOR_YELLOW \
COLOR_MAGENTA \
COLOR_CYAN
###########################################################
# Command line option variables
###########################################################
declare -l NOTIFICATION_METHOD
declare \
COMMAND \
DISPLAY_NOTIFICATIONS=false \
SHOW_VOLUME_PROGRESS=false \
USE_AMIXER=false \
USE_DUNSTIFY=false \
USE_FULLCOLOR_ICONS=false \
CARD \
MIXER=Master \
SIGNAL \
SINK \
STATUSLINE \
SYMBOLIC_ICON_SUFFIX \
NOTIFICATION_METHOD \
PLAY_SOUND=false \
USE_CANBERRA=false
declare -i \
EXPIRES=1500 \
MAX_VOLUME \
MAX_AMPLIFICATION=2
define_helpers
define_notify
define_output_formats
define_commands
setup_color
parse_opts "$@"
# Requires options to be parsed first
setup_audio
exec_command "${CMDARGS[*]}" && post_command_hook
exit ${EXITCODE:-$EX_OK}
}
main "$@"