pactl: A new volume widget using pactl only

Add a new volume widget that is using pactl only for controlling volume
and selecting sources and sinks. It therefore works with PulseAudio or
PipeWire as backend, unlike the original Volume widget.

The code is split as follows:
  - volume.lua contains the UI logic
  - pactl.lua contains the pactl interfacing and output parsing
  - utils.lua contains some shared helper routines

It is heavily based on the original Volume code and supports the same
configuration options and uses the same widget code.
This commit is contained in:
Stefan Huber 2022-12-30 16:15:54 +01:00
parent 5f251902cf
commit 81d725fe84
4 changed files with 439 additions and 0 deletions

54
pactl-widget/README.md Normal file
View file

@ -0,0 +1,54 @@
# Pactl volume widget
This is a volume widget that uses `pactl` only for controlling volume and
selecting sinks and sources. Hence, it can be used with PulseAudio or PipeWire
likewise, unlike the original Volume widget.
Other than that it is heavily based on the original widget, including its
customization and icon options. For screenshots, see the original widget.
## Installation
Clone the repo under **~/.config/awesome/** and add widget in **rc.lua**:
```lua
local volume_widget = require('awesome-wm-widgets.pactl-widget.volume')
...
s.mytasklist, -- Middle widget
{ -- Right widgets
layout = wibox.layout.fixed.horizontal,
...
-- default
volume_widget(),
-- customized
volume_widget{
widget_type = 'arc'
},
```
### Shortcuts
To improve responsiveness of the widget when volume level is changed by a shortcut use corresponding methods of the widget:
```lua
awful.key({}, "XF86AudioRaiseVolume", function () volume_widget:inc(5) end),
awful.key({}, "XF86AudioLowerVolume", function () volume_widget:dec(5) end),
awful.key({}, "XF86AudioMute", function () volume_widget:toggle() end),
```
## Customization
It is possible to customize the widget by providing a table with all or some of
the following config parameters:
### Generic parameter
| Name | Default | Description |
|---|---|---|
| `mixer_cmd` | `pavucontrol` | command to run on middle click (e.g. a mixer program) |
| `step` | `5` | How much the volume is raised or lowered at once (in %) |
| `widget_type`| `icon_and_text`| Widget type, one of `horizontal_bar`, `vertical_bar`, `icon`, `icon_and_text`, `arc` |
| `device` | `@DEFAULT_SINK@` | Select the device name to control |
For more details on parameters depending on the chosen widget type, please
refer to the original Volume widget.

124
pactl-widget/pactl.lua Normal file
View file

@ -0,0 +1,124 @@
local spawn = require("awful.spawn")
local utils = require("awesome-wm-widgets.pactl-widget.utils")
local pactl = {}
function pactl.volume_increase(device, step)
spawn('pactl set-sink-volume ' .. device .. ' +' .. step .. '%', false)
end
function pactl.volume_decrease(device, step)
spawn('pactl set-sink-volume ' .. device .. ' -' .. step .. '%', false)
end
function pactl.mute_toggle(device)
spawn('pactl set-sink-mute ' .. device .. ' toggle', false)
end
function pactl.get_volume(device)
local stdout = utils.popen_and_return('pactl get-sink-volume ' .. device)
local volsum, volcnt = 0, 0
for vol in string.gmatch(stdout, "(%d?%d?%d)%%") do
vol = tonumber(vol)
if vol ~= nil then
volsum = volsum + vol
volcnt = volcnt + 1
end
end
if volcnt == 0 then
return nil
end
return volsum / volcnt
end
function pactl.get_mute(device)
local stdout = utils.popen_and_return('pactl get-sink-mute ' .. device)
if string.find(stdout, "yes") then
return true
else
return false
end
end
function pactl.get_sinks_and_sources()
local default_sink = utils.trim(utils.popen_and_return('pactl get-default-sink'))
local default_source = utils.trim(utils.popen_and_return('pactl get-default-source'))
local sinks = {}
local sources = {}
local device
local ports
local key
local value
local in_section
for line in utils.popen_and_return('pactl list'):gmatch('[^\r\n]*') do
if string.match(line, '^%a+ #') then
in_section = nil
end
local is_sink_line = string.match(line, '^Sink #')
local is_source_line = string.match(line, '^Source #')
if is_sink_line or is_source_line then
in_section = "main"
device = {
id = line:match('#(%d+)'),
is_default = false
}
if is_sink_line then
table.insert(sinks, device)
else
table.insert(sources, device)
end
end
-- Found a new subsection
if in_section ~= nil and string.match(line, '^\t%a+:$') then
in_section = utils.trim(line):lower()
in_section = string.sub(in_section, 1, #in_section-1)
if in_section == 'ports' then
ports = {}
device['ports'] = ports
end
end
-- Found a key-value pair
if string.match(line, "^\t*[^\t]+: ") then
local t = utils.split(line, ':')
key = utils.trim(t[1]):lower():gsub(' ', '_')
value = utils.trim(t[2])
end
-- Key value pair on 1st level
if in_section ~= nil and string.match(line, "^\t[^\t]+: ") then
device[key] = value
if key == "name" and (value == default_sink or value == default_source) then
device['is_default'] = true
end
end
-- Key value pair in ports section
if in_section == "ports" and string.match(line, "^\t\t[^\t]+: ") then
ports[key] = value
end
end
return sinks, sources
end
function pactl.set_default(type, name)
spawn('pactl set-default-' .. type .. ' "' .. name .. '"', false)
end
return pactl

28
pactl-widget/utils.lua Normal file
View file

@ -0,0 +1,28 @@
local utils = {}
function utils.trim(str)
return string.match(str, "^%s*(.-)%s*$")
end
function utils.split(string_to_split, separator)
if separator == nil then separator = "%s" end
local t = {}
for str in string.gmatch(string_to_split, "([^".. separator .."]+)") do
table.insert(t, str)
end
return t
end
function utils.popen_and_return(cmd)
local handle = io.popen(cmd)
local result = handle:read("*a")
handle:close()
return result
end
return utils

233
pactl-widget/volume.lua Normal file
View file

@ -0,0 +1,233 @@
-------------------------------------------------
-- A purely pactl-based volume widget based on the original Volume widget
-- More details could be found here:
-- https://github.com/streetturtle/awesome-wm-widgets/tree/master/pactl-widget
-- @author Stefan Huber
-- @copyright 2023 Stefan Huber
-------------------------------------------------
local awful = require("awful")
local wibox = require("wibox")
local spawn = require("awful.spawn")
local gears = require("gears")
local beautiful = require("beautiful")
local pactl = require("awesome-wm-widgets.pactl-widget.pactl")
local utils = require("awesome-wm-widgets.pactl-widget.utils")
local widget_types = {
icon_and_text = require("awesome-wm-widgets.volume-widget.widgets.icon-and-text-widget"),
icon = require("awesome-wm-widgets.volume-widget.widgets.icon-widget"),
arc = require("awesome-wm-widgets.volume-widget.widgets.arc-widget"),
horizontal_bar = require("awesome-wm-widgets.volume-widget.widgets.horizontal-bar-widget"),
vertical_bar = require("awesome-wm-widgets.volume-widget.widgets.vertical-bar-widget")
}
local volume = {}
local rows = { layout = wibox.layout.fixed.vertical }
local popup = awful.popup{
bg = beautiful.bg_normal,
ontop = true,
visible = false,
shape = gears.shape.rounded_rect,
border_width = 1,
border_color = beautiful.bg_focus,
maximum_width = 400,
offset = { y = 5 },
widget = {}
}
local function build_main_line(device)
if device.active_port ~= nil and device.ports[device.active_port] ~= nil then
return device.description .. ' · ' .. utils.split(device.ports[device.active_port], " ")[1]
else
return device.description
end
end
local function build_rows(devices, on_checkbox_click, device_type)
local device_rows = { layout = wibox.layout.fixed.vertical }
for _, device in pairs(devices) do
local checkbox = wibox.widget {
checked = device.is_default,
color = beautiful.bg_normal,
paddings = 2,
shape = gears.shape.circle,
forced_width = 20,
forced_height = 20,
check_color = beautiful.fg_urgent,
widget = wibox.widget.checkbox
}
checkbox:connect_signal("button::press", function()
pactl.set_default(device_type, device.name)
on_checkbox_click()
end)
local row = wibox.widget {
{
{
{
checkbox,
valign = 'center',
layout = wibox.container.place,
},
{
{
text = build_main_line(device),
align = 'left',
widget = wibox.widget.textbox
},
left = 10,
layout = wibox.container.margin
},
spacing = 8,
layout = wibox.layout.align.horizontal
},
margins = 4,
layout = wibox.container.margin
},
bg = beautiful.bg_normal,
widget = wibox.container.background
}
row:connect_signal("mouse::enter", function(c) c:set_bg(beautiful.bg_focus) end)
row:connect_signal("mouse::leave", function(c) c:set_bg(beautiful.bg_normal) end)
local old_cursor, old_wibox
row:connect_signal("mouse::enter", function()
local wb = mouse.current_wibox
old_cursor, old_wibox = wb.cursor, wb
wb.cursor = "hand1"
end)
row:connect_signal("mouse::leave", function()
if old_wibox then
old_wibox.cursor = old_cursor
old_wibox = nil
end
end)
row:connect_signal("button::press", function()
pactl.set_default(device_type, device.name)
on_checkbox_click()
end)
table.insert(device_rows, row)
end
return device_rows
end
local function build_header_row(text)
return wibox.widget{
{
markup = "<b>" .. text .. "</b>",
align = 'center',
widget = wibox.widget.textbox
},
bg = beautiful.bg_normal,
widget = wibox.container.background
}
end
local function rebuild_popup()
for i = 0, #rows do
rows[i]=nil
end
local sinks, sources = pactl.get_sinks_and_sources()
table.insert(rows, build_header_row("SINKS"))
table.insert(rows, build_rows(sinks, function() rebuild_popup() end, "sink"))
table.insert(rows, build_header_row("SOURCES"))
table.insert(rows, build_rows(sources, function() rebuild_popup() end, "source"))
popup:setup(rows)
end
local function worker(user_args)
local args = user_args or {}
local mixer_cmd = args.mixer_cmd or 'pavucontrol'
local widget_type = args.widget_type
local refresh_rate = args.refresh_rate or 1
local step = args.step or 5
local device = args.device or '@DEFAULT_SINK@'
if widget_types[widget_type] == nil then
volume.widget = widget_types['icon_and_text'].get_widget(args.icon_and_text_args)
else
volume.widget = widget_types[widget_type].get_widget(args)
end
local function update_graphic(widget)
local vol = pactl.get_volume(device)
if vol ~= nil then
widget:set_volume_level(vol)
end
if pactl.get_mute(device) then
widget:mute()
else
widget:unmute()
end
end
function volume:inc(s)
pactl.volume_increase(device, s or step)
update_graphic(volume.widget)
end
function volume:dec(s)
pactl.volume_decrease(device, s or step)
update_graphic(volume.widget)
end
function volume:toggle()
pactl.mute_toggle(device)
update_graphic(volume.widget)
end
function volume:popup()
if popup.visible then
popup.visible = not popup.visible
else
rebuild_popup()
popup:move_next_to(mouse.current_widget_geometry)
end
end
function volume:mixer()
if mixer_cmd then
spawn(mixer_cmd)
end
end
volume.widget:buttons(
awful.util.table.join(
awful.button({}, 1, function() volume:toggle() end),
awful.button({}, 2, function() volume:mixer() end),
awful.button({}, 3, function() volume:popup() end),
awful.button({}, 4, function() volume:inc() end),
awful.button({}, 5, function() volume:dec() end)
)
)
gears.timer {
timeout = refresh_rate,
call_now = true,
autostart = true,
callback = function()
update_graphic(volume.widget)
end
}
return volume.widget
end
return setmetatable(volume, { __call = function(_, ...) return worker(...) end })