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