import asyncio
import re
import subprocess
from libqtile import bar
from libqtile.command.base import expose_command
from libqtile.log_utils import logger
from libqtile.utils import acall_process, create_task
from libqtile.widget import base
__all__ = [
"Volume",
]
re_vol = re.compile(r"(\d?\d?\d?)%")
class VolumeBase(base._TextBox):
orientations = base.ORIENTATION_HORIZONTAL
defaults = [
("padding", 3, "Padding left and right. Calculated if None."),
("theme_path", None, "Path of the icons"),
(
"emoji",
False,
"Use emoji to display volume states, only if ``theme_path`` is not set."
"The specified font needs to contain the correct unicode characters.",
),
(
"emoji_list",
["\U0001f507", "\U0001f508", "\U0001f509", "\U0001f50a"],
"List of emojis/font-symbols to display volume states, only if ``emoji`` is set."
" List contains 4 symbols, from lowest volume to highest.",
),
("mute_foreground", None, "Foreground color for mute volume."),
("mute_format", "M", "Format to display when volume is muted."),
("unmute_format", "{volume}%", "Format of text to display when volume is not muted."),
(
"step",
2,
"Volume change for up an down commands in percentage."
"Only used if ``volume_up_command`` and ``volume_down_command`` are not set.",
),
]
def __init__(self, **config):
base._TextBox.__init__(self, "", **config)
self.add_defaults(VolumeBase.defaults)
self.surfaces = {}
self.volume = None
self.is_mute = False
def _configure(self, qtile, parent_bar):
if self.theme_path:
self.length_type = bar.STATIC
self.length = 0
base._TextBox._configure(self, qtile, parent_bar)
self.unmute_foreground = self.foreground
def _update_drawer(self):
if self.mute_foreground is not None:
self.layout.colour = self.mute_foreground if self.is_mute else self.unmute_foreground
if self.theme_path:
self.drawer.clear(self.background or self.bar.background)
if self.volume <= 0 or self.is_mute:
img_name = "audio-volume-muted"
elif self.volume <= 30:
img_name = "audio-volume-low"
elif self.volume < 80:
img_name = "audio-volume-medium"
else: # self.volume >= 80:
img_name = "audio-volume-high"
self.drawer.ctx.set_source(self.surfaces[img_name])
self.drawer.ctx.paint()
elif self.emoji:
if len(self.emoji_list) < 4:
self.emoji_list = ["\U0001f507", "\U0001f508", "\U0001f509", "\U0001f50a"]
logger.warning(
"Emoji list given has less than 4 items. Falling back to default emojis."
)
if self.volume <= 0 or self.is_mute:
self.text = self.emoji_list[0]
elif self.volume <= 30:
self.text = self.emoji_list[1]
elif self.volume < 80:
self.text = self.emoji_list[2]
elif self.volume >= 80:
self.text = self.emoji_list[3]
else:
self.text = (
self.mute_format if self.is_mute or self.volume < 0 else self.unmute_format
).format(volume=self.volume)
def setup_images(self):
from libqtile import images
names = (
"audio-volume-high",
"audio-volume-low",
"audio-volume-medium",
"audio-volume-muted",
)
d_images = images.Loader(self.theme_path)(*names)
new_height = self.bar.size - 2
for name, img in d_images.items():
img.resize(height=new_height)
if img.width > self.length:
self.length = img.width + self.padding * 2
self.surfaces[name] = img.pattern
def draw(self):
if self.theme_path:
self.draw_at_default_position()
else:
base._TextBox.draw(self)
[docs]
class Volume(VolumeBase):
"""Widget that display and change volume
By default, this widget uses ``amixer`` to get and set the volume so users
will need to make sure this is installed. Alternatively, users may set the
relevant parameters for the widget to use a different application.
If theme_path is set it draw widget as icons.
"""
defaults = [
("cardid", None, "Card Id"),
("device", "default", "Device Name"),
("channel", "Master", "Channel"),
("update_interval", 0.2, "Update time in seconds."),
("mute_command", None, "Mute command"),
("volume_app", None, "App to control volume"),
("volume_up_command", None, "Volume up command"),
("volume_down_command", None, "Volume down command"),
(
"get_volume_command",
None,
"Command to get the current volume. "
"The expected output should include 1-3 numbers and a ``%`` sign.",
),
("check_mute_command", None, "Command to check mute status"),
(
"check_mute_string",
"[off]",
"String expected from check_mute_command when volume is muted."
"When the output of the command matches this string, the"
"audio source is treated as muted.",
),
]
def __init__(self, **config):
VolumeBase.__init__(self, **config)
self.add_defaults(Volume.defaults)
self.add_callbacks(
{
"Button1": self.mute,
"Button3": self.run_app,
"Button4": self.increase_vol,
"Button5": self.decrease_vol,
}
)
self._volume_task = None
def timer_setup(self):
self._volume_task = create_task(self.do_volume())
if self.theme_path:
self.setup_images()
def create_amixer_command(self, *args) -> str:
cmd = ["amixer"]
if self.cardid is not None:
cmd.extend(["-c", str(self.cardid)])
if self.device is not None:
cmd.extend(["-D", str(self.device)])
cmd.extend([x for x in args])
return subprocess.list2cmdline(cmd)
def button_press(self, x, y, button):
base._TextBox.button_press(self, x, y, button)
self.draw()
async def do_volume(self):
vol, muted = await self.get_volume()
if vol != self.volume or muted != self.is_mute:
self.volume = vol
self.is_mute = muted
# Update the underlying canvas size before actually attempting
# to figure out how big it is and draw it.
self._update_drawer()
self.bar.draw()
await asyncio.sleep(self.update_interval)
self._volume_task = create_task(self.do_volume())
async def get_volume(self):
try:
if self.get_volume_command is not None:
get_volume_cmd = self.get_volume_command
else:
get_volume_cmd = self.create_amixer_command("sget", self.channel)
mixer_out = await acall_process(get_volume_cmd, shell=True)
except subprocess.CalledProcessError:
return -1, False
check_mute = mixer_out
if self.check_mute_command:
check_mute = await acall_process(self.check_mute_command, shell=True)
muted = self.check_mute_string in check_mute
volgroups = re_vol.search(mixer_out)
if volgroups:
return int(volgroups.groups()[0]), muted
else:
# this shouldn't happen
return -1, muted
[docs]
@expose_command()
def increase_vol(self):
if self.volume_up_command is not None:
volume_up_cmd = self.volume_up_command
else:
volume_up_cmd = self.create_amixer_command(
"-q", "sset", self.channel, f"{self.step}%+"
)
subprocess.call(volume_up_cmd, shell=True)
[docs]
@expose_command()
def decrease_vol(self):
if self.volume_down_command is not None:
volume_down_cmd = self.volume_down_command
else:
volume_down_cmd = self.create_amixer_command(
"-q", "sset", self.channel, f"{self.step}%-"
)
subprocess.call(volume_down_cmd, shell=True)
[docs]
@expose_command()
def mute(self):
if self.mute_command is not None:
mute_cmd = self.mute_command
else:
mute_cmd = self.create_amixer_command("-q", "sset", self.channel, "toggle")
subprocess.call(mute_cmd, shell=True)
[docs]
@expose_command()
def run_app(self):
if self.volume_app is not None:
subprocess.Popen(self.volume_app, shell=True)
def finalize(self):
if self._volume_task is not None:
self._volume_task.cancel()
super().finalize()