diff --git a/doc/meson.build b/doc/meson.build index a1550c4..e3c47ac 100644 --- a/doc/meson.build +++ b/doc/meson.build @@ -9,6 +9,7 @@ foreach man_src : ['yambar.1.scd', 'yambar.5.scd', 'yambar-decorations.5.scd', 'yambar-modules-foreign-toplevel.5.scd', 'yambar-modules-i3.5.scd', 'yambar-modules-label.5.scd', 'yambar-modules-mpd.5.scd', 'yambar-modules-network.5.scd', + 'yambar-modules-pulse.5.scd', 'yambar-modules-removables.5.scd', 'yambar-modules-river.5.scd', 'yambar-modules-script.5.scd', 'yambar-modules-sway-xkb.5.scd', 'yambar-modules-sway.5.scd', 'yambar-modules-xkb.5.scd', diff --git a/doc/yambar-modules-pulse.5.scd b/doc/yambar-modules-pulse.5.scd new file mode 100644 index 0000000..2086aaf --- /dev/null +++ b/doc/yambar-modules-pulse.5.scd @@ -0,0 +1,62 @@ +yambar-modules-pulse(5) + +# NAME +pulse - Monitors one pulseaudio source and sink for volume and mute/unmute changes + +# TAGS + +[[ *Name* +:[ *Type* +:[ *Description* +| online +: bool +: True when the Pulseaudio connection has successfully been opened +| sink_online +: bool +: True when the Pulseaudio sink (output) exists and is usable +| source_online +: bool +: True when the Pulseaudio source (input) exists and is usable +| sink_percent +: range +: Sink (output) volume level in percentage, with min and max as start and end range values +| source_percent +: range +: Source (input) volume level in percentage, with min and max as start and end range values +| sink_muted +: bool +: True if sink is muted, otherwise false +| source_muted +: bool +: True if source is muted, otherwise false + + +# CONFIGURATION + +[[ *Name* +:[ *Type* +:[ *Req* +:[ *Description* +| sink_name +: string +: no +: Sink name to monitor. Defaults to _@DEFAULT\_SINK@_ +| source_name +: string +: no +: Source name to monitor. Defaults to _@DEFAULT\_SOURCE@_ + + +# EXAMPLES + +``` +bar: + left: + - pulse: + content: {string: {text: "{sink_percent} %"}} +``` + +# SEE ALSO + +*yambar-modules*(5), *yambar-particles*(5), *yambar-tags*(5), *yambar-decorations*(5) + diff --git a/examples/configurations/laptop.conf b/examples/configurations/laptop.conf index eab6c46..b73e3c3 100644 --- a/examples/configurations/laptop.conf +++ b/examples/configurations/laptop.conf @@ -226,6 +226,54 @@ bar: "": - string: {text: , font: *awesome, foreground: ffffff66} - string: {text: "{ssid}", foreground: ffffff66} + - pulse: + anchors: + - string: &common {on-click: "pavucontrol"} + - string: &offline {foreground: dc322fff} + - string: &muted {foreground: 839496ff} + - in: &in + - string: {<<: *common, text: "", font: *awesome} + - string: {<<: *common, text: "{source_percent}%"} + - out: &out + - string: {<<: *common, text: "🔊︁", font: *awesome} + - string: {<<: *common, text: "{sink_percent}%"} + - in_muted: &in_muted + - string: {<<: [*common, *muted], text: "", font: *awesome} + - string: {<<: [*common, *muted], text: "{source_percent}%"} + - out_muted: &out_muted + - string: {<<: [*common, *muted], text: "", font: *awesome} + - string: {<<: [*common, *muted], text: "{sink_percent}%"} + + content: + map: + tag: sink_online + values: + false: + - string: {<<: [*offline, *common], text: , font: *awesome} + true: + map: + tag: sink_muted + values: + true: + map: + tag: source_muted + values: + true: + - *out_muted + - *in_muted + false: + - *out_muted + - *in + false: + map: + tag: source_muted + values: + true: + - *out + - *in_muted + false: + - *out + - *in - alsa: card: hw:PCH mixer: Master diff --git a/meson.build b/meson.build index ed4d0d1..af8f9a7 100644 --- a/meson.build +++ b/meson.build @@ -125,7 +125,8 @@ yambar = executable( version, dependencies: [bar, libepoll, libinotify, pixman, yaml, threads, dl, tllist, fcft] + decorations + particles + modules, - c_args: [plugin_mpd_enabled? '-DPLUGIN_ENABLED_MPD':[]], + c_args: [plugin_mpd_enabled? '-DPLUGIN_ENABLED_MPD':[], + plugin_pulse_enabled? '-DPLUGIN_ENABLED_PULSE':[]], build_rpath: '$ORIGIN/modules:$ORIGIN/decorations:$ORIGIN/particles', export_dynamic: true, install: true, @@ -162,7 +163,10 @@ summary( ) summary( - {'Music Player Daemon (MPD)': plugin_mpd_enabled}, + { + 'Music Player Daemon (MPD)': plugin_mpd_enabled, + 'Pulseaudio': plugin_pulse_enabled, + }, section: 'Optional modules', bool_yn: true ) diff --git a/meson_options.txt b/meson_options.txt index 15dc3e9..b589840 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -8,3 +8,6 @@ option( option( 'plugin-mpd', type: 'feature', value: 'auto', description: 'Music Player Daemon (MPD) support') +option( + 'plugin-pulse', type: 'feature', value: 'auto', + description: 'Pulseaudio support') diff --git a/modules/meson.build b/modules/meson.build index 6b64958..13b8dc8 100644 --- a/modules/meson.build +++ b/modules/meson.build @@ -11,6 +11,9 @@ xcb_xkb = dependency('xcb-xkb', required: get_option('backend-x11')) mpd = dependency('libmpdclient', required: get_option('plugin-mpd')) plugin_mpd_enabled = mpd.found() +pulse = dependency('libpulse', required: get_option('plugin-pulse')) +plugin_pulse_enabled = pulse.found() + # Module name -> (source-list, dep-list) mod_data = { 'alsa': [[], [m, alsa]], @@ -29,6 +32,10 @@ if plugin_mpd_enabled mod_data += {'mpd': [[], [mpd]]} endif +if plugin_pulse_enabled + mod_data += {'pulse': [[], [pulse]]} +endif + if backend_x11 mod_data += { 'xkb': [[], [xcb_stuff, xcb_xkb]], diff --git a/modules/pulse.c b/modules/pulse.c new file mode 100644 index 0000000..a8257b3 --- /dev/null +++ b/modules/pulse.c @@ -0,0 +1,433 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pulse/pulseaudio.h" + +#define LOG_MODULE "pulse" +#define LOG_ENABLE_DBG 1 + +#include "../bar/bar.h" +#include "../config-verify.h" +#include "../config.h" +#include "../log.h" +#include "../plugin.h" + +struct private +{ + char *sink_name; + char *source_name; + struct particle *label; + + bool sink_online; + bool sink_muted; + pa_volume_t sink_cur_volume; + + bool source_online; + bool source_muted; + pa_volume_t source_cur_volume; + + bool online; + + uint sink_index; + uint source_index; + char *current_sink_name; + char *current_source_name; +}; + +static void +destroy(struct module *mod) +{ + struct private *m = mod->private; + m->label->destroy(m->label); + free(m->current_sink_name); + free(m->current_source_name); + free(m->sink_name); + free(m->source_name); + free(m); + module_default_destroy(mod); +} + +static const char * +description(struct module *mod) +{ + static char desc[32]; + snprintf(desc, sizeof(desc), "pulse"); + return desc; +} + +static struct exposable * +content(struct module *mod) +{ + struct private *p = mod->private; + + mtx_lock(&mod->lock); + + int sink_percent = (p->sink_cur_volume * 100 + PA_VOLUME_NORM / 2) / PA_VOLUME_NORM; + int source_percent = (p->source_cur_volume * 100 + PA_VOLUME_NORM / 2) / PA_VOLUME_NORM; + + // Note that Pulseaudio source/sink can go over 100% volume + struct tag_set tags = { + .tags = + (struct tag *[]){ + tag_new_bool(mod, "online", p->online), + tag_new_bool(mod, "sink_online", p->sink_online), + tag_new_bool(mod, "sink_muted", p->sink_muted), + tag_new_int_range(mod, "sink_percent", sink_percent, 0, 100), + tag_new_bool(mod, "source_online", p->source_online), + tag_new_bool(mod, "source_muted", p->source_muted), + tag_new_int_range(mod, "source_percent", source_percent, 0, 100), + }, + .count = 7, + }; + mtx_unlock(&mod->lock); + + struct exposable *exposable = p->label->instantiate(p->label, &tags); + + tag_set_destroy(&tags); + return exposable; +} + +void +sink_info_callback(pa_context *c, const pa_sink_info *i, int eol, void *userdata) +{ + struct module *mod = (struct module *)userdata; + struct private *p = mod->private; + if (!i) + return; + + // Average of the channels + pa_volume_t vol = pa_cvolume_avg(&i->volume); + + mtx_lock(&mod->lock); + p->sink_cur_volume = vol; + p->sink_muted = i->mute; + p->sink_online = true; + p->sink_index = i->index; + mtx_unlock(&mod->lock); + + mod->bar->refresh(mod->bar); +} + +void +source_info_callback(pa_context *c, const pa_source_info *i, int eol, void *userdata) +{ + struct module *mod = (struct module *)userdata; + struct private *p = mod->private; + if (!i) + return; + + // Average of the channels + pa_volume_t vol = pa_cvolume_avg(&i->volume); + + mtx_lock(&mod->lock); + p->source_cur_volume = vol; + p->source_online = true; + p->source_muted = i->mute; + p->source_index = i->index; + mtx_unlock(&mod->lock); + + mod->bar->refresh(mod->bar); +} + +void +server_info_callback(pa_context *c, const pa_server_info *i, void *userdata) +{ + struct module *mod = (struct module *)userdata; + struct private *p = mod->private; + pa_operation *o; + + mtx_lock(&mod->lock); + // If they match defaults that means there are no sinks/sources + if (strcmp(i->default_sink_name, "@DEFAULT_SINK@") == 0) { + p->sink_online = false; + p->sink_index = 0; + mod->bar->refresh(mod->bar); + } + if (strcmp(i->default_source_name, "@DEFAULT_SOURCE@") == 0) { + p->source_online = false; + p->source_index = 0; + mod->bar->refresh(mod->bar); + } + + if (!p->current_sink_name || strcmp(i->default_sink_name, p->current_sink_name) != 0) { + LOG_DBG("Default sink changed (%s) - calling get_sink_info_by_name", i->default_sink_name); + free(p->current_sink_name); + p->current_sink_name = strdup(i->default_sink_name); + if (!(o = pa_context_get_sink_info_by_name(c, p->sink_name, sink_info_callback, + userdata))) { + LOG_ERR("pa_context_get_sink_info_by_name() failed: %s", + pa_strerror(pa_context_errno(c))); + } + pa_operation_unref(o); + } else if (!p->current_source_name || + strcmp(i->default_source_name, p->current_source_name) != 0) { + LOG_DBG("Default source changed (%s) - calling get_sink_info_by_name", + i->default_source_name); + free(p->current_source_name); + p->current_source_name = strdup(i->default_source_name); + if (!(o = pa_context_get_source_info_by_name(c, p->source_name, source_info_callback, + userdata))) { + LOG_ERR("pa_context_get_source_info_by_name() failed: %s", + pa_strerror(pa_context_errno(c))); + } + pa_operation_unref(o); + } + + mtx_unlock(&mod->lock); +} + +/* + We subscribe to events on sink/sources and server configuration changes + + Sinks and sources so that we can react to volume/mute changes for example. + Server change subscription gets triggered when default sink/source changes + (among other things) so it lets us immediately react +*/ +void +pa_subscription_callback(pa_context *c, pa_subscription_event_type_t t, uint idx, void *userdata) +{ + struct module *mod = (struct module *)userdata; + struct private *p = mod->private; + pa_operation *o; + + mtx_lock(&mod->lock); + switch (t & PA_SUBSCRIPTION_EVENT_FACILITY_MASK) { + case PA_SUBSCRIPTION_EVENT_SINK: + if ((t & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE) { + if (idx == p->sink_index) { + p->sink_online = false; + p->sink_index = 0; + } + } + + if (!p->sink_index || p->sink_index == idx) { + LOG_DBG("calling get_sink_info_by_name: %d", idx); + if (!(o = pa_context_get_sink_info_by_name(c, p->sink_name, sink_info_callback, + userdata))) { + LOG_ERR("pa_context_get_sink_info_by_name() failed: %s", + pa_strerror(pa_context_errno(c))); + } + pa_operation_unref(o); + } + break; + case PA_SUBSCRIPTION_EVENT_SOURCE: + if ((t & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE) { + if (idx == p->source_index) { + p->source_online = false; + p->source_index = 0; + } + } + + if (!p->source_index || p->source_index == idx) { + LOG_DBG("calling get_source_info_by_name: %d", idx); + if (!(o = pa_context_get_source_info_by_name(c, p->source_name, source_info_callback, + userdata))) { + LOG_ERR("pa_context_get_source_info_by_name() failed: %s", + pa_strerror(pa_context_errno(c))); + } + pa_operation_unref(o); + } + case PA_SUBSCRIPTION_EVENT_SERVER: + // Update when server state changes - for example changes in default sink/source + if (!(o = pa_context_get_server_info(c, server_info_callback, userdata))) { + LOG_ERR("pa_context_get_server_info() failed: %s", pa_strerror(pa_context_errno(c))); + } + pa_operation_unref(o); + break; + } + mtx_unlock(&mod->lock); +} + +/* This is called whenever the context status changes - we connect/disconnect etc */ +static void +context_state_callback(pa_context *c, void *userdata) +{ + assert(c); + struct module *mod = (struct module *)userdata; + struct private *p = mod->private; + LOG_DBG("Context state callback called"); + + switch (pa_context_get_state(c)) { + case PA_CONTEXT_CONNECTING: + case PA_CONTEXT_AUTHORIZING: + case PA_CONTEXT_SETTING_NAME: + mtx_lock(&mod->lock); + p->online = false; + mtx_unlock(&mod->lock); + break; + + case PA_CONTEXT_READY: { + pa_operation *o; + assert(c); + mtx_lock(&mod->lock); + p->online = true; + mtx_unlock(&mod->lock); + LOG_DBG("pulse connection established."); + pa_context_set_subscribe_callback(c, pa_subscription_callback, mod); + if (!(o = pa_context_subscribe(c, + PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SOURCE | + PA_SUBSCRIPTION_MASK_SERVER, + NULL, mod))) { + LOG_ERR("pa_context_subscribe failed: %s", pa_strerror(pa_context_errno(c))); + } + + if (!(o = pa_context_get_sink_info_by_name(c, p->sink_name, sink_info_callback, mod))) { + LOG_ERR("pa_context_get_sink_info_by_name failed: %s", + pa_strerror(pa_context_errno(c))); + } + if (!(o = pa_context_get_source_info_by_name(c, p->source_name, source_info_callback, + mod))) { + LOG_ERR("pa_context_get_source_info_by_name failed: %s", + pa_strerror(pa_context_errno(c))); + } + pa_operation_unref(o); + break; + } + + case PA_CONTEXT_TERMINATED: + mtx_lock(&mod->lock); + p->online = false; + p->sink_online = false; + p->source_online = false; + mtx_unlock(&mod->lock); + LOG_INFO("pulse connection terminated."); + mod->bar->refresh(mod->bar); + // TODO - possibly schedule a recurring reconnect attempt? + break; + + case PA_CONTEXT_FAILED: + default: + mtx_lock(&mod->lock); + p->online = false; + p->sink_online = false; + p->source_online = false; + mtx_unlock(&mod->lock); + LOG_ERR("pulse connection failure: %s", pa_strerror(pa_context_errno(c))); + mod->bar->refresh(mod->bar); + // TODO - possibly schedule a recurring reconnect attempt? + break; + } + return; +} + +int +run(struct module *mod) +{ + int ret = 1; + pa_threaded_mainloop *m = NULL; + pa_context *context = NULL; + pa_mainloop_api *mainloop_api = NULL; + + if (!(m = pa_threaded_mainloop_new())) { + LOG_ERR("pa_mainloop_new() failed"); + goto out; + } + + mainloop_api = pa_threaded_mainloop_get_api(m); + + if (!(context = pa_context_new(mainloop_api, NULL))) { + LOG_ERR("pa_context_new() failed."); + goto out; + } + + pa_context_set_state_callback(context, context_state_callback, mod); + + /* Connect the context */ + if (pa_context_connect(context, NULL, 0, NULL) < 0) { + LOG_ERR("pa_context_connect() failed: %s\n", pa_strerror(pa_context_errno(context))); + goto out; + } + + /* Let's start a separate thread for handling all pulseaudio events */ + pa_threaded_mainloop_start(m); + + /* And wait for termination event from abort_fd */ + while (true) { + struct pollfd fds[] = {{.fd = mod->abort_fd, .events = POLLIN}}; + int r = poll(fds, sizeof(fds) / sizeof(fds[0]), -1); + if (r < 0) { + if (errno == EINTR) + continue; + + LOG_ERRNO("failed to poll"); + goto out; + } + + if (fds[0].revents & (POLLIN | POLLHUP)) { + // This is the only way to exit without an error + ret = 0; + goto out; + } + } + +out: + if (m) { + pa_threaded_mainloop_stop(m); + pa_threaded_mainloop_free(m); + } + + return ret; +} + +static struct module * +pulse_new(const char *sink_name, const char *source_name, struct particle *label) +{ + struct private *priv = calloc(1, sizeof(*priv)); + priv->label = label; + priv->sink_name = strdup(sink_name); + priv->source_name = strdup(source_name); + priv->source_online = false; + priv->sink_online = false; + priv->online = false; + + struct module *mod = module_common_new(); + mod->private = priv; + mod->run = &run; + mod->destroy = &destroy; + mod->content = &content; + mod->description = &description; + return mod; +} + +static struct module * +from_conf(const struct yml_node *node, struct conf_inherit inherited) +{ + const struct yml_node *sink_name = yml_get_value(node, "sink_name"); + const struct yml_node *source_name = yml_get_value(node, "source_name"); + const struct yml_node *content = yml_get_value(node, "content"); + + return pulse_new(sink_name != NULL ? yml_value_as_string(sink_name) : "@DEFAULT_SINK@", + source_name != NULL ? yml_value_as_string(source_name) : "@DEFAULT_SOURCE@", + conf_to_particle(content, inherited)); +} + +static bool +verify_conf(keychain_t *chain, const struct yml_node *node) +{ + static const struct attr_info attrs[] = { + {"sink_name", false, &conf_verify_string}, + {"source_name", false, &conf_verify_string}, + MODULE_COMMON_ATTRS, + }; + + return conf_verify_dict(chain, node, attrs); +} + +const struct module_iface module_pulse_iface = { + .verify_conf = &verify_conf, + .from_conf = &from_conf, +}; + +#if defined(CORE_PLUGINS_AS_SHARED_LIBRARIES) +extern const struct module_iface iface __attribute__((weak, alias("module_pulse_iface"))); +#endif diff --git a/plugin.c b/plugin.c index 00b0061..9d604c0 100644 --- a/plugin.c +++ b/plugin.c @@ -43,6 +43,9 @@ EXTERN_MODULE(label); EXTERN_MODULE(mpd); #endif EXTERN_MODULE(network); +#if defined(PLUGIN_ENABLED_PULSE) +EXTERN_MODULE(pulse); +#endif EXTERN_MODULE(removables); EXTERN_MODULE(river); EXTERN_MODULE(sway_xkb); @@ -123,6 +126,9 @@ init(void) REGISTER_CORE_MODULE(mpd, mpd); #endif REGISTER_CORE_MODULE(network, network); +#if defined(PLUGIN_ENABLED_PULSE) + REGISTER_CORE_MODULE(pulse, pulse); +#endif REGISTER_CORE_MODULE(removables, removables); #if defined(HAVE_PLUGIN_river) REGISTER_CORE_MODULE(river, river);