Make the progress bars and graphs more configurable and themable
[delightful.git] / delightful / widgets / pulseaudio.lua
1 -------------------------------------------------------------------------------
2 --
3 -- PulseAudio mixer widget for Awesome 3.5
4 -- Copyright (C) 2011-2016 Tuomas Jormola <tj@solitudo.net>
5 --
6 -- Licensed under the terms of GNU General Public License Version 2.0.
7 --
8 -- Description:
9 --
10 -- Shows a mixer display and control for PulseAudio sinks.
11 -- Volume level is indicated as a vertical progress bar and
12 -- an icon is shown next to it. Clicking the icon performs
13 -- some mixer-related actions. Left-click will mute the sink,
14 -- right-click launcheŃ• external mixer application (if configured),
15 -- and using the scroll wheel will pump up and down the volume.
16 --
17 -- Widget uses Vicious widget framework to gather widget data.
18 --
19 -- Widget tries to use icons from the package gnome-icon-theme
20 -- if available.
21 --
22 --
23 -- Configuration:
24 --
25 -- The load() function can be supplied with configuration.
26 -- Format of the configuration is as follows.
27 -- {
28 -- -- PulseAudio sink IDs as listed in the "index:" line of output of
29 -- -- "pacmd list-sinks" command. This can be used to limit mixer controls
30 -- -- to certain sinks only. By default, widget creates controls for
31 -- -- all the sinks in the system. The example shows 1st and 3rd sink.
32 --        sink_nums          = { 0, 2 },
33 -- -- Whether to try to start PulseAudio if reading of the sink data
34 -- -- fails. Default is true.
35 --        pulseaudio_start   = true,
36 -- -- Path to pulseaudio command. 'pulseaudio' by default.
37 --        pulseaudio_command = '/usr/local/bin/pulseaudio',
38 -- -- Path to pacmd command. 'pacmd' by default.
39 --        pacmd_command      = '/usr/local/bin/pacmd',
40 -- -- Command to execute when right-clicking the widget icon.
41 -- -- Empty by default.
42 --        mixer_command      = 'pavucontrol',
43 -- -- Don't try to display any icons. Default is false (i.e. display icons).
44 --        no_icon            = true,
45 -- -- Height of the progress bar in pixels. Default is 19.
46 --        progressbar_height = 19,
47 -- -- Width of the progress bar in pixels. Default is 8.
48 --        progressbar_width  = 12,
49 -- -- How often update the widget data. Default is 10 seconds.
50 --        update_interval    = 30
51 -- }
52 --
53 --
54 -- Theme:
55 --
56 -- The widget uses following settings, colors and icons if available in
57 -- the Awesome theme.
58 --
59 -- theme.progressbar_height  - height of the volume progress bar in pixels
60 -- theme.progressbar_width   - width of the volume progress bar in pixels
61 -- theme.bg_widget           - widget background color
62 -- theme.fg_widget           - widget foreground color
63 -- theme.fg_center_widget    - widget gradient color, middle
64 -- theme.fg_end_widget       - widget gradient color, end
65 -- theme.delightful_vol      - default icon, this is also used as
66 --                             the max, med, min, and zero icons if these
67 --                             are not specified
68 -- theme.delightful_vol_max  - icon shown when volume level is high
69 -- theme.delightful_vol_med  - icon shown when volume level is medium
70 -- theme.delightful_vol_min  - icon shown when volume level is low
71 -- theme.delightful_vol_zero - icon shown when volume level is at the bottom
72 -- theme.delightful_vol_mute - icon shown when sink is muted
73 -- theme.delightful_error    - icon shown when critical error has occurred
74 --
75 -------------------------------------------------------------------------------
76
77 local awful      = require('awful')
78 local wibox      = require('wibox')
79 local beautiful  = require('beautiful')
80
81 local delightful = { utils = require('delightful.utils') }
82 local vicious    = require('vicious')
83
84 local io           = { popen = io.popen }
85 local math         = { floor = math.floor }
86 local os           = { execute = os.execute, time = os.time }
87 local pairs        = pairs
88 local setmetatable = setmetatable
89 local string       = { format = string.format }
90 local table        = { insert = table.insert, remove = table.remove }
91 local tonumber     = tonumber
92 local type         = type
93
94 module('delightful.widgets.pulseaudio')
95
96 local maxvol             = 65536
97 local volstep            = 5
98
99 local widgets            = {}
100 local icons              = {}
101 local icon_files         = {}
102 local prev_icons         = {}
103 local tooltips           = {}
104 local sink_data          = {}
105 local new_data           = {}
106 local number_of_sinks
107 local fatal_error
108 local retry_fatal_error  = true
109 local pulseaudio_config
110 local pacmd_string
111 local pacmd_timestamp
112 local pacmd_force_update = false
113
114 local config_description = {
115         {
116                 name     = 'sink_nums',
117                 coerce   = function(value) return delightful.utils.coerce_table(value) end,
118                 validate = function(value) return delightful.utils.config_table(value) end
119         },
120         {
121                 name     = 'pulseaudio_start',
122                 required = true,
123                 default  = true,
124                 validate = function(value) return delightful.utils.config_boolean(value) end
125         },
126         {
127                 name     = 'pulseaudio_command',
128                 required = true,
129                 default  = 'pulseaudio',
130                 validate = function(value) return delightful.utils.config_string(value) end
131         },
132         {
133                 name     = 'pacmd_command',
134                 required = true,
135                 default  = 'pacmd',
136                 validate = function(value) return delightful.utils.config_string(value) end
137         },
138         {
139                 name     = 'mixer_command',
140                 default  = function(config_data) if mixer_cmd then return mixer_cmd end end,
141                 validate = function(value) return delightful.utils.config_string(value) end
142         },
143         {
144                 name     = 'no_icon',
145                 validate = function(value) return delightful.utils.config_boolean(value) end
146         },
147         {
148                 name     = 'progressbar_height',
149                 required = true,
150                 default  = 19,
151                 validate = function(value) return delightful.utils.config_int(value) end
152         },
153         {
154                 name     = 'progressbar_width',
155                 required = true,
156                 default  = 8,
157                 validate = function(value) return delightful.utils.config_int(value) end
158         },
159         {
160                 name     = 'update_interval',
161                 required = true,
162                 default  = 10,
163                 validate = function(value) return delightful.utils.config_int(value) end
164         },
165 }
166
167 local icon_description = {
168         vol   = { beautiful_name = 'delightful_vol',      default_icon = 'multimedia-volume-control' },
169         max   = { beautiful_name = 'delightful_vol_max',  default_icon = 'audio-volume-high'         },
170         med   = { beautiful_name = 'delightful_vol_med',  default_icon = 'audio-volume-medium'       },
171         min   = { beautiful_name = 'delightful_vol_min',  default_icon = 'audio-volume-low'          },
172         zero  = { beautiful_name = 'delightful_vol_zero', default_icon = 'audio-volume-low',         },
173         mute  = { beautiful_name = 'delightful_vol_mute', default_icon = 'audio-volume-muted'        },
174         error = { beautiful_name = 'delightful_error',    default_icon = 'dialog-error'              },
175 }
176
177 -- Read sink info
178 function update_data(force_update)
179         update_sink_string(force_update)
180         sink_data = {}
181         for i = 1, number_of_sinks do
182                 sink_data[i] = {}
183         end
184         if not pacmd_string or fatal_error then
185                 return
186         end
187         local sink_id = 0
188         local sink_num_ok
189         -- iterate all lines in "pacmd list-sinks" output
190         pacmd_string:gsub('(.-)\n', function(line)
191                         -- parse sink id
192                         line:gsub('^[%s\*]+index:%s(%d)$', function(match)
193                                         sink_num_ok = false
194                                         local sink_num = tonumber(match)
195                                         for accepted_sink_id, accepted_sink_num in pairs(pulseaudio_config.sink_nums) do
196                                                 sink_num_ok = sink_num == accepted_sink_num
197                                                 if sink_num_ok then
198                                                         sink_id = accepted_sink_id
199                                                         sink_data[sink_id].num = sink_num
200                                                         break
201                                                 end
202                                         end
203                         end)
204                         -- parse mute status
205                         line:gsub('^%s+muted:%s(.+)$', function(match)
206                                         if not sink_num_ok then
207                                                 return
208                                         end
209                                         sink_data[sink_id].muted = match == 'yes'
210                         end)
211                         -- parse volume
212                         line:gsub('^%s+volume:[%s%w-:/]+%s(%d+)%%', function(match)
213                                         if not sink_num_ok then
214                                                 return
215                                         end
216                                         sink_data[sink_id].volperc = tonumber(match)
217                                         sink_data[sink_id].volnum  = math.floor(((maxvol / 100) * sink_data[sink_id].volperc) + 0.5)
218                         end)
219                         -- parse device name
220                         line:gsub('^%s+device\.description%s+=%s+[\'"]([^\'"]+)[\'"]$', function(match)
221                                         if not sink_num_ok then
222                                                 return
223                                         end
224                                         sink_data[sink_id].name = match
225                         end)
226         end)
227         -- ensure all required info was found
228         for found_sink_id, found_sink_data in pairs(sink_data) do
229                 if not (found_sink_data.name and found_sink_data.muted ~= nil and found_sink_data.volperc) then
230                         sink_data[found_sink_id] = {
231                                 error_string = 'Failed to get required info about PulseAudio sink'
232                         }
233                 end
234         end
235 end
236
237 -- Update widget icon based on the volume
238 function update_icon(sink_id)
239         if not icons[sink_id] or not icon_files.error then
240                 return
241         end
242         local icon_file
243         if (fatal_error or (sink_data[sink_id] and sink_data[sink_id].error_string)) and icon_files.error then
244                 icon_file = icon_files.error
245         elseif sink_data[sink_id] and icon_files.vol then
246                 icon_file = icon_files.vol
247                 if sink_data[sink_id].muted then
248                         icon_file = icon_files.mute
249                 elseif sink_data[sink_id].volperc then
250                         if     sink_data[sink_id].volperc > 100 * 0.7 then
251                                 icon_file = icon_files.max
252                         elseif sink_data[sink_id].volperc > 100 * 0.3 then
253                                 icon_file = icon_files.med
254                         elseif sink_data[sink_id].volperc > 0 then
255                                 icon_file = icon_files.min
256                         elseif sink_data[sink_id].volperc == 0 then
257                                 icon_file = icon_files.zero
258                         end
259                 end
260         end
261         if icon_file and (not prev_icons[sink_id] or prev_icons[sink_id] ~= icon_file) then
262                 prev_icons[sink_id] = icon_file
263                 icons[sink_id]:set_image(icon_file)
264         end
265 end
266
267 -- Update the mixer tooltip text
268 function update_tooltip(sink_id)
269         if not tooltips[sink_id] or not sink_data[sink_id]then
270                 return
271         end
272         local text
273         if fatal_error then
274                 text = string.format(' %s ', fatal_error)
275         elseif sink_data[sink_id].error_string then
276                 text = string.format(' %s ', sink_data[sink_id].error_string)
277         else
278                 local volume_text = 'Unknown'
279                 if sink_data[sink_id].muted then
280                         volume_text = 'muted'
281                 elseif sink_data[sink_id].volperc then
282                         volume_text = string.format('%d%%', sink_data[sink_id].volperc)
283                 end
284                 text = string.format(' Audio device %s \n Volume level: %s \n Volume controls: \n Left mouse button: toggle mute on and off \n Right mouse button: launch mixer \n Scrollwheel up and down: rise and lower volume ', sink_data[sink_id].name, volume_text)
285
286         end
287         tooltips[sink_id]:set_text(text)
288 end
289
290 -- Configuration handler
291 function handle_config(user_config)
292         local empty_config = delightful.utils.get_empty_config(config_description)
293         if not user_config then
294                 user_config = empty_config
295         end
296         local config_data = delightful.utils.normalize_config(user_config, config_description)
297         local validation_errors = delightful.utils.validate_config(config_data, config_description)
298         if validation_errors then
299                 fatal_error = 'Configuration errors: \n'
300                 for error_index, error_entry in pairs(validation_errors) do
301                         fatal_error = string.format('%s %s', fatal_error, error_entry)
302                         if error_index < #validation_errors then
303                                 fatal_error = string.format('%s \n', fatal_error)
304                         end
305                 end
306                 retry_fatal_error = false
307                 pulseaudio_config = empty_config
308                 return
309         end
310         pulseaudio_config = config_data
311 end
312
313 -- Initalization
314 function load(self, config)
315         handle_config(config)
316         if not pulseaudio_config.no_icon then
317                 icon_files = delightful.utils.find_icon_files(icon_description)
318         end
319         update_sink_string()
320         update_number_of_sinks()
321         if not pulseaudio_config.sink_nums then
322                 pulseaudio_config.sink_nums = {}
323                 for sink_id = 1, number_of_sinks do
324                         table.insert(pulseaudio_config.sink_nums, sink_id - 1)
325                 end
326         end
327
328         local bg_color        = delightful.utils.find_theme_color({ 'bg_widget', 'bg_normal'                     })
329         local fg_color        = delightful.utils.find_theme_color({ 'fg_widget', 'fg_normal'                     })
330         local fg_center_color = delightful.utils.find_theme_color({ 'fg_center_widget', 'fg_widget', 'fg_normal' })
331         local fg_end_color    = delightful.utils.find_theme_color({ 'fg_end_widget', 'fg_widget', 'fg_normal'    })
332
333         for sink_id = 1, number_of_sinks do
334                 if icon_files.vol and icon_files.error then
335                         local buttons = awful.util.table.join(
336                                         awful.button({}, 1, function()
337                                                         if sink_data[sink_id] and not fatal_error and not sink_data[sink_id].error_string then
338                                                                 pulseaudio_control('toggle', sink_id)
339                                                         end
340                                         end),
341                                         awful.button({}, 3, function()
342                                                         if sink_data[sink_id] and not fatal_error and not sink_data[sink_id].error_string then
343                                                                 if pulseaudio_config.mixer_command then
344                                                                         awful.util.spawn(pulseaudio_config.mixer_command, true)
345                                                                 end
346                                                         end
347                                         end),
348                                         awful.button({}, 4, function()
349                                                         if sink_data[sink_id] and not fatal_error and not sink_data[sink_id].error_string then
350                                                                 pulseaudio_control('up', sink_id)
351                                                         end
352                                         end),
353                                         awful.button({}, 5, function()
354                                                         if sink_data[sink_id] and not fatal_error and not sink_data[sink_id].error_string then
355                                                                 pulseaudio_control('down', sink_id)
356                                                         end
357                                         end)
358                         )
359                         icons[sink_id] = wibox.widget.imagebox()
360                         icons[sink_id]:buttons(buttons)
361                         tooltips[sink_id] = awful.tooltip( { objects = { icons[sink_id] } })
362                         update_icon(sink_id)
363                         update_tooltip(sink_id)
364                 end
365                 local widget = awful.widget.progressbar()
366                 if bg_color then
367                         widget:set_border_color(bg_color)
368                         widget:set_background_color(bg_color)
369                 end
370                 local color_args = fg_color
371                 local height = beautiful.progressbar_height or pulseaudio_config.progressbar_height
372                 local width  = beautiful.progressbar_width  or pulseaudio_config.progressbar_width
373                 if fg_color and fg_center_color and fg_end_color then
374                         color_args = {
375                                 type = 'linear',
376                                 from = { 0, 0 },
377                                 to = { width, height },
378                                 stops = {{ 0, fg_end_color }, { 0.5, fg_center_color }, { 1, fg_color }},
379                         }
380                 end
381                 widget:set_color(color_args)
382                 widget:set_width(width)
383                 widget:set_height(height)
384                 widget:set_vertical(true)
385                 widgets[sink_id] = widget
386                 vicious.register(widget, self, '$1', pulseaudio_config.update_interval, sink_id)
387         end
388         return widgets, icons
389 end
390
391 -- Vicious worker function
392 function vicious_worker(format, sink_id)
393         update_data(pacmd_force_update)
394         update_icon(sink_id)
395         update_tooltip(sink_id)
396         pacmd_force_update = false
397         if fatal_error then
398                 delightful.utils.print_error('pulseaudio', fatal_error)
399                 return 0
400         end
401         if not sink_data[sink_id] then
402                 return 0
403         end
404         if sink_data[sink_id].error_string then
405                 delightful.utils.print_error('pulseaudio', sink_data[sink_id].error_string)
406                 return 0
407         end
408         return sink_data[sink_id].volperc
409 end
410
411 -- Sink helpers
412
413 function update_sink_string(force_update)
414         if not retry_fatal_error then
415                 return
416         end
417         local now = os.time()
418         local pacmd_command = pulseaudio_config.pacmd_command .. ' list-sinks'
419         if force_update or not pacmd_string or (pacmd_timestamp and now - pacmd_timestamp >= pulseaudio_config.update_interval) then
420                 pacmd_string = awful.util.pread(pacmd_command)
421                 pacmd_timestamp = now
422         end
423         if not pacmd_string or #pacmd_string == 0 then
424                 pacmd_string = nil
425                 -- try starting PulseAudio
426                 if pulseaudio_config.pulseaudio_start then
427                         awful.util.spawn(pulseaudio_config.pulseaudio_command, false)
428                         os.execute('sleep 1')
429                         pacmd_string = awful.util.pread(pacmd_command)
430                         if not pacmd_string or #pacmd_string == 0 then
431                                 pacmd_string = nil
432                                 fatal_error = 'Tried to start PulseAudio, but failed list PulseAudio sinks. Is PulseAudio installed and properly configured?'
433                                 return
434                         end
435                 else
436                         fatal_error = 'Failed to list PulseAudio sinks. Is PulseAudio installed and running?'
437                         return
438                 end
439         end
440 end
441
442 function update_number_of_sinks()
443         if number_of_sinks then
444                 return
445         end
446         number_of_sinks = 0
447         if pulseaudio_config.sink_nums then
448                 number_of_sinks = #pulseaudio_config.sink_nums
449         elseif pacmd_string then
450                 pacmd_string:gsub('(.-)\n', function(line)
451                                 line:gsub('^[%s\*]+index:%s%d$', function(match)
452                                                 number_of_sinks = number_of_sinks + 1
453                                 end)
454                 end)
455         end
456         if number_of_sinks == 0 then
457                 number_of_sinks = 1
458                 local error_string = 'Failed to detect PulseAudio sinks'
459                 if fatal_error then
460                         error_string = string.format('%s: %s', error_string, fatal_error)
461                 end
462                 fatal_error = error_string
463         end
464 end
465
466 -- PulseAudio volume control functions
467
468 function pulseaudio_control(command, sink_id)
469         if sink_data[sink_id] and not fatal_error and not sink_data[sink_id].error_string then
470                 if command == 'toggle' then
471                         pulseaudio_toggle(sink_id)
472                 elseif command == 'up' then
473                         pulseaudio_set_volume(sink_id, volstep)
474                 elseif command == 'down' then
475                         pulseaudio_set_volume(sink_id, -volstep)
476                 end
477         end
478         pacmd_force_update = true
479         vicious.force({ widgets[sink_id] })
480         update_icon(sink_id)
481 end
482
483 function pulseaudio_set_volume(sink_id, step)
484         if not sink_data[sink_id] or fatal_error or sink_data[sink_id].error_string or not sink_data[sink_id].volperc then
485                 return
486         end
487         local volperc_new = sink_data[sink_id].volperc + step
488         if volperc_new > 100 then
489                 volperc_new = 100
490         elseif volperc_new < 0 then
491                 volperc_new = 0
492         end
493         local volnum_new = math.floor(((maxvol / 100) * volperc_new) + 0.5)
494         if volnum_new ~= sink_data[sink_id].volnum then
495                 awful.util.spawn('pacmd set-sink-volume ' .. sink_data[sink_id].num .. ' ' .. volnum_new, false)
496                 sink_data[sink_id].volperc = volperc_new
497                 sink_data[sink_id].volnum = volnum_new
498         end
499 end
500
501 function pulseaudio_toggle(sink_id)
502         if not sink_data[sink_id] or fatal_error or sink_data[sink_id].error_string then
503                 return
504         elseif sink_data[sink_id].muted ~= nil then
505                 if sink_data[sink_id].muted then
506                         pulseaudio_unmute(sink_id)
507                 else
508                         pulseaudio_mute(sink_id)
509                 end
510         end
511 end
512
513 function pulseaudio_mute(sink_id)
514         if not sink_data[sink_id] or fatal_error or sink_data[sink_id].error_string then
515                 return
516         end
517         awful.util.spawn('pacmd set-sink-mute ' .. sink_data[sink_id].num .. ' 1', false)
518         sink_data[sink_id].muted = true
519 end
520
521 function pulseaudio_unmute(sink_id)
522         if not sink_data[sink_id] or fatal_error or sink_data[sink_id].error_string then
523                 return
524         end
525         awful.util.spawn('pacmd set-sink-mute ' .. sink_data[sink_id].num .. ' 0', false)
526         sink_data[sink_id].muted = false
527 end
528
529 setmetatable(_M, { __call = function(_, ...) return vicious_worker(...) end })