~rkintzi/statusbar

9fa9acec843a7b02fa0c6552de1a2d2eede6341c — RadosÅ‚aw Kintzi 10 months ago 7df9509
Add Volume widget
5 files changed, 458 insertions(+), 0 deletions(-)

A cmds/pavolmon/README.md
A cmds/pavolmon/volmon.cpp
M go.mod
M go.sum
A widgets/volume.go
A cmds/pavolmon/README.md => cmds/pavolmon/README.md +12 -0
@@ 0,0 1,12 @@
This simple program connects to the PulseAudio server and constantly monitors
the volume level of a default output device. Whenever it changes it prints
on the standard output a single line containg the level.

Installation:

```
g++ -o pavolmon $(pkg-config -libs libpulse) -lm ./cmds/pavolmon/volmon.cpp
sudo cp pavolmon /usr/local/bin
```

This program is needed by the Volume widget.

A cmds/pavolmon/volmon.cpp => cmds/pavolmon/volmon.cpp +255 -0
@@ 0,0 1,255 @@
/**
 * Author: Jason White
 * License: Public Domain
 *
 * Description:
 * This is a simple test program to hook into PulseAudio volume change
 * notifications. It was created for the possibility of having an automatically
 * updating volume widget in a tiling window manager status bar.
 *
 * Compiling:
 *
 *     g++ $(pkg-config libpulse --cflags --libs) pulsetest.c -o pulsetest
 *
 * Modified by Radosław Kintzi to print voulme of a default sink
 */
#include <assert.h>
#include <signal.h>
#include <pulse/pulseaudio.h>
#include <cstring>
#include <iostream>
#include <cmath>

class PulseAudio
{
private:
    pa_mainloop* _mainloop;
    pa_mainloop_api* _mainloop_api;
    pa_context* _context;
    pa_signal_event* _signal;
    const char *_default_sink_name;
public:
    PulseAudio()
        : _mainloop(NULL), _mainloop_api(NULL), _context(NULL), _signal(NULL)
    {
    }

    /**
     * Initializes state and connects to the PulseAudio server.
     */
    bool initialize()
    {
        _mainloop = pa_mainloop_new();
        if (!_mainloop)
        {
            std::cerr<< "pa_mainloop_new() failed.\n";
            return false;
        }

        _mainloop_api = pa_mainloop_get_api(_mainloop);

        if (pa_signal_init(_mainloop_api) != 0)
        {
            std::cerr << "pa_signal_init() failed\n";
            return false;
        }

        _signal = pa_signal_new(SIGINT, exit_signal_callback, this);
        if (!_signal)
        {
            std::cerr << "pa_signal_new() failed\n";
            return false;
        }
        signal(SIGPIPE, SIG_IGN);

        _context = pa_context_new(_mainloop_api, "PulseAudio Test");
        if (!_context)
        {
            std::cerr << "pa_context_new() failed\n";
            return false;
        }

        if (pa_context_connect(_context, NULL, PA_CONTEXT_NOAUTOSPAWN, NULL) < 0)
        {
            std::cerr << "pa_context_connect() failed: " << pa_strerror(pa_context_errno(_context)) << "\n";
            return false;
        }

        pa_context_set_state_callback(_context, context_state_callback, this);

        return true;
    }

    /**
     * Runs the main PulseAudio event loop. Calling quit will cause the event
     * loop to exit.
     */
    int run()
    {
        int ret = 1;
        if (pa_mainloop_run(_mainloop, &ret) < 0)
        {
            std::cerr << "pa_mainloop_run() failed.\n";
            return ret;
        }

        return ret;
    }

    /**
     * Exits the main loop with the specified return code.
     */
    void quit(int ret = 0)
    {
        _mainloop_api->quit(_mainloop_api, ret);
    }

    /**
     * Called when the PulseAudio system is to be destroyed.
     */
    void destroy()
    {
        if (_context)
        {
            pa_context_unref(_context);
            _context = NULL;
        }

        if (_signal)
        {
            pa_signal_free(_signal);
            pa_signal_done();
            _signal = NULL;
        }

        if (_mainloop)
        {
            pa_mainloop_free(_mainloop);
            _mainloop = NULL;
            _mainloop_api = NULL;
        }
    }

    ~PulseAudio()
    {
        destroy();
    }

private:

    /*
     * Called on SIGINT.
     */
    static void exit_signal_callback(pa_mainloop_api *m, pa_signal_event *e, int sig, void *userdata)
    {
        PulseAudio* pa = (PulseAudio*)userdata;
        if (pa) pa->quit();
    }

    /*
     * Called whenever the context status changes.
     */
    static void context_state_callback(pa_context *c, void *userdata)
    {
        assert(c && userdata);

        PulseAudio* pa = (PulseAudio*)userdata;

        switch (pa_context_get_state(c))
        {
            case PA_CONTEXT_CONNECTING:
            case PA_CONTEXT_AUTHORIZING:
            case PA_CONTEXT_SETTING_NAME:
                break;

            case PA_CONTEXT_READY:
                std::cerr <<"PulseAudio connection established.\n";
                pa_context_get_server_info(c, server_info_callback, userdata);

                // Subscribe to sink events from the server. This is how we get
                // volume change notifications from the server.
                pa_context_set_subscribe_callback(c, subscribe_callback, userdata);
                pa_context_subscribe(c, (pa_subscription_mask_t)(PA_SUBSCRIPTION_MASK_SINK|PA_SUBSCRIPTION_MASK_SERVER), NULL, NULL);
                break;

            case PA_CONTEXT_TERMINATED:
                pa->quit(0);
                std::cerr << "PulseAudio connection terminated.\n";
                break;

            case PA_CONTEXT_FAILED:
            default:
                std::cerr<< "Connection failure: " << pa_strerror(pa_context_errno(c)) << "\n";
                pa->quit(1);
                break;
        }
    }

    /*
     * Called when an event we subscribed to occurs.
     */
    static void subscribe_callback(pa_context *c,
            pa_subscription_event_type_t type, uint32_t idx, void *userdata)
    {
        unsigned facility = type & PA_SUBSCRIPTION_EVENT_FACILITY_MASK;
        //type &= PA_SUBSCRIPTION_EVENT_TYPE_MASK;

        pa_operation *op = NULL;

        switch (facility)
        {
            case PA_SUBSCRIPTION_EVENT_SINK:
                op = pa_context_get_sink_info_by_index(c, idx, sink_info_callback, userdata);
                break;
            case PA_SUBSCRIPTION_EVENT_SERVER:
                op= pa_context_get_server_info(c, server_info_callback, userdata);
                break;

            default:
                assert(0); // Got event we aren't expecting.
                break;
        }

        if (op)
            pa_operation_unref(op);
    }

    /*
     * Called when the requested sink information is ready.
     */
    static void sink_info_callback(pa_context *c, const pa_sink_info *i,
            int eol, void *userdata)
    {
        PulseAudio* pa = (PulseAudio*)userdata;
        if (i && strcmp(pa->_default_sink_name, i->name)==0)
        {
            float volume = (float)pa_cvolume_avg(&(i->volume)) / (float)PA_VOLUME_NORM;
            std::cout << std::round(volume * 100.0f) << (i->mute ? "m" :"") << "\n";
            std::cout.flush();
        }
    }

    /*
     * Called when the requested information on the server is ready. This is
     * used to find the default PulseAudio sink.
     */
    static void server_info_callback(pa_context *c, const pa_server_info *i,
            void *userdata)
    {
        PulseAudio* pa = (PulseAudio*)userdata;
        pa->_default_sink_name = i->default_sink_name;
        pa_context_get_sink_info_by_name(c, i->default_sink_name, sink_info_callback, userdata);
    }
};


int main(int argc, char *argv[])
{
    PulseAudio pa = PulseAudio();
    if (!pa.initialize())
        return 0;

    int ret = pa.run();

    return ret;
}

M go.mod => go.mod +1 -0
@@ 6,6 6,7 @@ require (
	github.com/adrg/xdg v0.4.0
	github.com/godbus/dbus/v5 v5.1.0
	github.com/google/go-cmp v0.6.0
	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
	github.com/shirou/gopsutil/v3 v3.23.10
	gopkg.in/yaml.v3 v3.0.1
)

M go.sum => go.sum +2 -0
@@ 11,6 11,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

A widgets/volume.go => widgets/volume.go +188 -0
@@ 0,0 1,188 @@
package widgets

import (
	"bufio"
	"context"
	"io"
	"os/exec"
	"strconv"
	"strings"
	"sync"

	"github.com/google/shlex"
	"gobytes.dev/statusbar"
	"gobytes.dev/statusbar/internal/registry"
)

type Volume struct {
	Format        string `yaml:"format"`
	Icon          string `yaml:"icon"`
	MuteIcon      string `yaml:"muteIcon"`
	VolMonCmd     string `yaml:"volMonCommand"`
	VolUpCmd      string `yaml:"volUpCommand"`
	VolDownCmd    string `yaml:"volDownCommand"`
	MuteToggleCmd string `yaml:"muteToggleCommand"`
	OnClickCmd    string `yaml:"onClickCommand"`

	volMonCmd     *exec.Cmd
	volUpCmd      []string
	volDownCmd    []string
	muteToggleCmd []string
	onClickCmd    []string

	fmt     Format
	rc      io.ReadCloser
	procErr error

	baseWidget
}

func (w *Volume) WidgetName() string    { return "Volume" }
func (w *Volume) New() statusbar.Widget { return new(Volume) }

func (w *Volume) Run(ctx context.Context, update statusbar.UpdateFunc, done func()) {
	defer done()
	w.init()
	if w.volMonCmd == nil {
		w.errorf("No volMonCommand provided")
		return
	}
	rc, err := w.volMonCmd.StdoutPipe()
	if err != nil {
		w.errorf(err.Error())
		return
	}
	w.rc = rc
	var wg sync.WaitGroup
	ctx, cancel := context.WithCancel(ctx)
	end := func() {
		cancel()
		wg.Done()
	}
	wg.Add(2)
	go w.readVol(ctx, end, update)
	go w.run(ctx, end, w.volMonCmd)
	wg.Wait()
	w.errorf(w.procErr.Error())
}

func (w *Volume) Event(ev statusbar.Event) {
	var cmd []string
	switch ev.Button {
	case 4:
		cmd = w.volUpCmd
	case 5:
		cmd = w.volDownCmd
	case 1:
		cmd = w.onClickCmd
	case 3:
		cmd = w.muteToggleCmd
	}
	if len(cmd) == 0 {
		return
	}
	go func() {
		c := exec.Command(cmd[0], cmd[1:]...)
		err := c.Run()
		if err != nil {
			w.errorf(err.Error())
		}
	}()
}

func (w *Volume) run(ctx context.Context, done func(), cmd *exec.Cmd) {
	defer done()
	w.procErr = cmd.Run()
}

func (w *Volume) readVol(ctx context.Context, done func(), update statusbar.UpdateFunc) {
	defer done()
	buf := bufio.NewReader(w.rc)
	line, err := buf.ReadString('\n')
	for ; err == nil; line, err = buf.ReadString('\n') {
		var mute bool
		line = strings.TrimSpace(line)
		mute = strings.HasSuffix(line, "m")
		line = strings.TrimSuffix(line, "m")
		vol, err := strconv.Atoi(line)
		if err != nil {
			w.errorf(err.Error())
			continue
		}
		w.emitVol(vol, mute, update)
	}
	if err != nil {
		w.errorf(err.Error())
	}
}

func (w *Volume) emitVol(vol int, mute bool, update statusbar.UpdateFunc) {
	icon := w.Icon
	if mute {
		icon = w.MuteIcon
	}
	status := w.fmt.Format(icon, vol)
	update([]statusbar.Block{
		{
			FullText: status,
			Align:    "left",
			Name:     "volmon",
			Instance: "0",
			Markup:   "pango",
		},
	})
}

func (w *Volume) init() {
	w.prefix = "Volume"
	if w.Format == "" {
		w.Format = "{icon} {level}"
	}
	if w.Icon == "" {
		w.Icon = "\ue892"
	}
	if w.MuteIcon == "" {
		w.MuteIcon = "\ue890"
	}
	w.fmt = ParseFormat(w.Format, "icon:%s", "level:%d")
	if w.VolMonCmd == "" {
		w.VolMonCmd = "pavolmon"
	}
	if cmd, err := shlex.Split(w.VolMonCmd); err != nil {
		w.errorf(err.Error())
	} else {
		w.volMonCmd = exec.Command(cmd[0], cmd[1:]...)
	}
	if w.VolUpCmd != "" {
		if cmd, err := shlex.Split(w.VolUpCmd); err != nil {
			w.errorf(err.Error())
		} else {
			w.volUpCmd = cmd
		}
	}
	if w.VolDownCmd != "" {
		if cmd, err := shlex.Split(w.VolDownCmd); err != nil {
			w.errorf(err.Error())
		} else {
			w.volDownCmd = cmd
		}
	}
	if w.MuteToggleCmd != "" {
		if cmd, err := shlex.Split(w.MuteToggleCmd); err != nil {
			w.errorf(err.Error())
		} else {
			w.muteToggleCmd = cmd
		}
	}
	if w.OnClickCmd != "" {
		if cmd, err := shlex.Split(w.OnClickCmd); err != nil {
			w.errorf(err.Error())
		} else {
			w.onClickCmd = cmd
		}
	}
}

func init() {
	registry.RegisterWidget(new(Volume))
}