Skip to content

Commit 101f7a5

Browse files
authored
Merge pull request #475 from SPFabGerman/wpctl-widget
Add wpctl volume widget
2 parents 5d58830 + 996efdb commit 101f7a5

File tree

3 files changed

+350
-0
lines changed

3 files changed

+350
-0
lines changed

wpctl-widget/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Wpctl volume widget
2+
3+
This is a volume widget for PipeWire, that uses `wpctl` for controlling volume and
4+
selecting sinks and sources.
5+
6+
It is heavily based on the `pactl` and original volume widget, including its
7+
customization and icon options. For screenshots, see the original widget.
8+
9+
## Installation
10+
11+
Clone the repo under **~/.config/awesome/** and add widget in **rc.lua**:
12+
13+
```lua
14+
local volume_widget = require('awesome-wm-widgets.wpctl-widget.volume')
15+
...
16+
s.mytasklist, -- Middle widget
17+
{ -- Right widgets
18+
layout = wibox.layout.fixed.horizontal,
19+
...
20+
-- default
21+
volume_widget(),
22+
-- customized
23+
volume_widget{
24+
widget_type = 'arc'
25+
},
26+
```
27+
28+
### Shortcuts
29+
30+
To improve responsiveness of the widget when volume level is changed by a shortcut use corresponding methods of the widget:
31+
32+
```lua
33+
awful.key({}, "XF86AudioRaiseVolume", function () volume_widget:inc(5) end),
34+
awful.key({}, "XF86AudioLowerVolume", function () volume_widget:dec(5) end),
35+
awful.key({}, "XF86AudioMute", function () volume_widget:toggle() end),
36+
```
37+
38+
## Customization
39+
40+
It is possible to customize the widget by providing a table with all or some of
41+
the following config parameters:
42+
43+
### Generic parameter
44+
45+
| Name | Default | Description |
46+
|---|---|---|
47+
| `mixer_cmd` | `pwvucontrol` | command to run on middle click (e.g. a mixer program) |
48+
| `step` | 5 | How much the volume is raised or lowered at once (in %) |
49+
| `widget_type`| `icon_and_text`| Widget type, one of `horizontal_bar`, `vertical_bar`, `icon`, `icon_and_text`, `arc` |
50+
| `device` | `@DEFAULT_SINK@` | Select the device id to control |
51+
| `tooltip` | false | Display volume level in a tooltip when the mouse cursor hovers the widget |
52+
53+
For more details on parameters depending on the chosen widget type, please
54+
refer to the original Volume widget.

wpctl-widget/volume.lua

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
-------------------------------------------------
2+
-- A purely wpctl-based volume widget based on the original Volume widget
3+
-- More details could be found here:
4+
-- https://github.com/streetturtle/awesome-wm-widgets/tree/master/wpctl-widget
5+
6+
-- @author Stefan Huber
7+
-- @copyright 2023 Stefan Huber
8+
-------------------------------------------------
9+
10+
local awful = require("awful")
11+
local wibox = require("wibox")
12+
local spawn = require("awful.spawn")
13+
local gears = require("gears")
14+
local beautiful = require("beautiful")
15+
16+
local wpctl = require("awesome-wm-widgets.wpctl-widget.wpctl")
17+
18+
19+
local widget_types = {
20+
icon_and_text = require("awesome-wm-widgets.volume-widget.widgets.icon-and-text-widget"),
21+
icon = require("awesome-wm-widgets.volume-widget.widgets.icon-widget"),
22+
arc = require("awesome-wm-widgets.volume-widget.widgets.arc-widget"),
23+
horizontal_bar = require("awesome-wm-widgets.volume-widget.widgets.horizontal-bar-widget"),
24+
vertical_bar = require("awesome-wm-widgets.volume-widget.widgets.vertical-bar-widget")
25+
}
26+
local volume = {}
27+
28+
local rows = { layout = wibox.layout.fixed.vertical }
29+
30+
local popup = awful.popup{
31+
bg = beautiful.bg_normal,
32+
ontop = true,
33+
visible = false,
34+
shape = gears.shape.rounded_rect,
35+
border_width = 1,
36+
border_color = beautiful.bg_focus,
37+
maximum_width = 400,
38+
offset = { y = 5 },
39+
widget = {}
40+
}
41+
42+
local function build_rows(devices, on_checkbox_click, device_type)
43+
local device_rows = { layout = wibox.layout.fixed.vertical }
44+
for _, device in pairs(devices) do
45+
46+
local checkbox = wibox.widget {
47+
checked = device.is_default,
48+
color = beautiful.bg_normal,
49+
paddings = 2,
50+
shape = gears.shape.circle,
51+
forced_width = 20,
52+
forced_height = 20,
53+
check_color = beautiful.fg_urgent,
54+
widget = wibox.widget.checkbox
55+
}
56+
57+
checkbox:connect_signal("button::press", function()
58+
wpctl.set_default(device.id)
59+
on_checkbox_click()
60+
end)
61+
62+
local row = wibox.widget {
63+
{
64+
{
65+
{
66+
checkbox,
67+
valign = 'center',
68+
layout = wibox.container.place,
69+
},
70+
{
71+
{
72+
text = device.name,
73+
align = 'left',
74+
widget = wibox.widget.textbox
75+
},
76+
left = 10,
77+
layout = wibox.container.margin
78+
},
79+
spacing = 8,
80+
layout = wibox.layout.align.horizontal
81+
},
82+
margins = 4,
83+
layout = wibox.container.margin
84+
},
85+
bg = beautiful.bg_normal,
86+
widget = wibox.container.background
87+
}
88+
89+
row:connect_signal("mouse::enter", function(c) c:set_bg(beautiful.bg_focus) end)
90+
row:connect_signal("mouse::leave", function(c) c:set_bg(beautiful.bg_normal) end)
91+
92+
local old_cursor, old_wibox
93+
row:connect_signal("mouse::enter", function()
94+
local wb = mouse.current_wibox
95+
old_cursor, old_wibox = wb.cursor, wb
96+
wb.cursor = "hand1"
97+
end)
98+
row:connect_signal("mouse::leave", function()
99+
if old_wibox then
100+
old_wibox.cursor = old_cursor
101+
old_wibox = nil
102+
end
103+
end)
104+
105+
row:connect_signal("button::press", function()
106+
wpctl.set_default(device.id)
107+
on_checkbox_click()
108+
end)
109+
110+
table.insert(device_rows, row)
111+
end
112+
113+
return device_rows
114+
end
115+
116+
local function build_header_row(text)
117+
return wibox.widget{
118+
{
119+
markup = "<b>" .. text .. "</b>",
120+
align = 'center',
121+
widget = wibox.widget.textbox
122+
},
123+
bg = beautiful.bg_normal,
124+
widget = wibox.container.background
125+
}
126+
end
127+
128+
local function rebuild_popup()
129+
for i = 0, #rows do
130+
rows[i]=nil
131+
end
132+
133+
local sinks, sources = wpctl.get_sinks_and_sources()
134+
table.insert(rows, build_header_row("SINKS"))
135+
table.insert(rows, build_rows(sinks, function() rebuild_popup() end, "sink"))
136+
table.insert(rows, build_header_row("SOURCES"))
137+
table.insert(rows, build_rows(sources, function() rebuild_popup() end, "source"))
138+
139+
popup:setup(rows)
140+
end
141+
142+
local function worker(user_args)
143+
144+
local args = user_args or {}
145+
146+
local mixer_cmd = args.mixer_cmd or 'pwvucontrol'
147+
local widget_type = args.widget_type
148+
local refresh_rate = args.refresh_rate or 1
149+
local step = args.step or 5
150+
local device = args.device or '@DEFAULT_SINK@'
151+
local tooltip = args.tooltip or false
152+
153+
if widget_types[widget_type] == nil then
154+
volume.widget = widget_types['icon_and_text'].get_widget(args.icon_and_text_args)
155+
else
156+
volume.widget = widget_types[widget_type].get_widget(args)
157+
end
158+
159+
local function update_graphic(widget)
160+
local vol,mute = wpctl.get_volume_and_mute(device)
161+
if vol ~= nil then
162+
widget:set_volume_level(vol)
163+
end
164+
165+
if mute then
166+
widget:mute()
167+
else
168+
widget:unmute()
169+
end
170+
end
171+
172+
function volume:inc(s)
173+
wpctl.volume_increase(device, s or step)
174+
update_graphic(volume.widget)
175+
end
176+
177+
function volume:dec(s)
178+
wpctl.volume_decrease(device, s or step)
179+
update_graphic(volume.widget)
180+
end
181+
182+
function volume:toggle()
183+
wpctl.mute_toggle(device)
184+
update_graphic(volume.widget)
185+
end
186+
187+
function volume:popup()
188+
if popup.visible then
189+
popup.visible = not popup.visible
190+
else
191+
rebuild_popup()
192+
popup:move_next_to(mouse.current_widget_geometry)
193+
end
194+
end
195+
196+
function volume:mixer()
197+
if mixer_cmd then
198+
spawn(mixer_cmd)
199+
end
200+
end
201+
202+
volume.widget:buttons(
203+
awful.util.table.join(
204+
awful.button({}, 1, function() volume:toggle() end),
205+
awful.button({}, 2, function() volume:mixer() end),
206+
awful.button({}, 3, function() volume:popup() end),
207+
awful.button({}, 4, function() volume:inc() end),
208+
awful.button({}, 5, function() volume:dec() end)
209+
)
210+
)
211+
212+
gears.timer {
213+
timeout = refresh_rate,
214+
call_now = true,
215+
autostart = true,
216+
callback = function()
217+
update_graphic(volume.widget)
218+
end
219+
}
220+
221+
if tooltip then
222+
awful.tooltip {
223+
objects = { volume.widget },
224+
timer_function = function()
225+
local vol,mut = wpctl.get_volume(device)
226+
return vol .. "%"
227+
end,
228+
}
229+
end
230+
231+
return volume.widget
232+
end
233+
234+
235+
return setmetatable(volume, { __call = function(_, ...) return worker(...) end })

wpctl-widget/wpctl.lua

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
local spawn = require("awful.spawn")
2+
local utils = require("awesome-wm-widgets.pactl-widget.utils")
3+
4+
local wpctl = {}
5+
6+
7+
function wpctl.volume_increase(device, step)
8+
spawn('wpctl set-volume ' .. device .. ' ' .. step .. '%+', false)
9+
end
10+
11+
function wpctl.volume_decrease(device, step)
12+
spawn('wpctl set-volume ' .. device .. ' ' .. step .. '%-', false)
13+
end
14+
15+
function wpctl.mute_toggle(device)
16+
spawn('wpctl set-mute ' .. device .. ' toggle', false)
17+
end
18+
19+
function wpctl.get_volume_and_mute(device)
20+
local stdout = utils.popen_and_return('wpctl get-volume ' .. device)
21+
local vol = tonumber(string.match(stdout, "%d+%.%d+"))
22+
if vol ~= nil then
23+
vol = vol * 100
24+
end
25+
local mute = string.find(stdout, "MUTED") ~= nil
26+
return vol, mute
27+
end
28+
29+
function wpctl.get_sinks_and_sources()
30+
local sinks = {}
31+
local sources = {}
32+
local in_section
33+
local in_subsection
34+
35+
for line in utils.popen_and_return('wpctl status'):gmatch('[^\r\n]+') do
36+
in_section = string.match(line, "^%a+$") or in_section
37+
in_subsection = string.match(line, " (%a+):$") or in_subsection
38+
39+
if in_section == "Audio" and (in_subsection == "Sinks" or in_subsection == "Sources") then
40+
local id, name = string.match(line, " (%d+)%. ([%w- ]+)")
41+
if id and name then
42+
local is_default = string.find(line, "%*") ~= nil
43+
local device = { id = id, name = name, is_default = is_default }
44+
if in_subsection == "Sinks" then
45+
table.insert(sinks, device)
46+
else
47+
table.insert(sources, device)
48+
end
49+
end
50+
end
51+
end
52+
53+
return sinks, sources
54+
end
55+
56+
function wpctl.set_default(id)
57+
spawn('wpctl set-default ' .. id, false)
58+
end
59+
60+
61+
return wpctl

0 commit comments

Comments
 (0)