diff --git a/meson.build b/meson.build index d760e94..19488e7 100644 --- a/meson.build +++ b/meson.build @@ -178,6 +178,7 @@ summary( 'Foreign toplevel (window tracking for Wayland)': plugin_foreign_toplevel_enabled, 'Memory monitoring': plugin_mem_enabled, 'Music Player Daemon (MPD)': plugin_mpd_enabled, + 'Media Player Remote Interface Specificaion (MPRIS)': plugin_mpris_enabled, 'i3+Sway': plugin_i3_enabled, 'Label': plugin_label_enabled, 'Network monitoring': plugin_network_enabled, diff --git a/meson_options.txt b/meson_options.txt index a9aac05..351f231 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -26,6 +26,8 @@ option('plugin-mem', type: 'feature', value: 'auto', description: 'Memory monitoring support') option('plugin-mpd', type: 'feature', value: 'auto', description: 'Music Player Daemon (MPD) support') +option('plugin-mpris', type: 'feature', value: 'enabled', + description: 'Media Player Remote Interface Specificaion (MPRIS) support') option('plugin-i3', type: 'feature', value: 'auto', description: 'i3+Sway support') option('plugin-label', type: 'feature', value: 'auto', diff --git a/modules/meson.build b/modules/meson.build index e2ed56e..a225d05 100644 --- a/modules/meson.build +++ b/modules/meson.build @@ -22,6 +22,9 @@ plugin_mem_enabled = get_option('plugin-mem').allowed() mpd = dependency('libmpdclient', required: get_option('plugin-mpd')) plugin_mpd_enabled = mpd.found() +mpris = dependency('dbus-1', required: get_option('plugin-mpris')) +plugin_mpris_enabled = mpris.found() + json_i3 = dependency('json-c', required: get_option('plugin-i3')) plugin_i3_enabled = json_i3.found() @@ -89,6 +92,10 @@ if plugin_mpd_enabled mod_data += {'mpd': [[], [mpd]]} endif +if plugin_mpris_enabled + mod_data += {'mpris': [[], [mpris]]} +endif + if plugin_i3_enabled mod_data += {'i3': [['i3-common.c', 'i3-common.h'], [dynlist, json_i3]]} endif diff --git a/modules/mpris.c b/modules/mpris.c new file mode 100644 index 0000000..0c30e52 --- /dev/null +++ b/modules/mpris.c @@ -0,0 +1,623 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include + +#define LOG_MODULE "mpris" +#define LOG_ENABLE_DBG 1 +#include "../bar/bar.h" +#include "../config-verify.h" +#include "../config.h" +#include "../log.h" +#include "../plugin.h" + +enum mpris_playback_state { + MPRIS_PLAYBACK_INVALID, + MPRIS_PLAYBACK_STOPPED, + MPRIS_PLAYBACK_PLAYING, + MPRIS_PLAYBACK_PAUSED, +}; + +enum mpris_loop_state { + MPRIS_LOOP_INVALID, + MPRIS_LOOP_NONE, + MPRIS_LOOP_TRACK, + MPRIS_LOOP_PLAYLIST, +}; + +struct mpris_metadata { + uint64_t length_nsec; + char *trackid; + char *artists; + char *album; + char *title; +}; + +struct mpris_property { + enum mpris_playback_state playback_status; + enum mpris_loop_state loop_status; + struct mpris_metadata metadata; + uint64_t position_usec; + double rate; + double volume; + bool shuffle; +}; + +struct private +{ + struct particle *label; + /* TODO: This should be an array of options */ + const char *target_identity; + DBusConnection *connection; + + const char *target_bus_name; + const char *target_bus_identity; + + uint64_t previous_position_usec; + struct mpris_property property; + + struct { + uint64_t value_ns; + struct timespec when; + } elapsed; + + thrd_t refresh_thread_id; + int refresh_abort_fd; +}; + +/* DBus specific */ + +#define MPRIS_QUERY_TIMEOUT 50 + +#define MPRIS_PATH "/org/mpris/MediaPlayer2" +#define MPRIS_BUS "org.mpris.MediaPlayer2" +#define MPRIS_INTERFACE "org.mpris.MediaPlayer2" +#define MPRIS_INTERFACE_PLAYER MPRIS_INTERFACE ".Player" + +#define MPRIS_DBUS_PATH "/org/mpris/MediaPlayer2" +#define MPRIS_DBUS_BUS "org.freedesktop.DBus" +#define MPRIS_DBUS_INTERFACE "org.freedesktop.DBus" + +/* TODO: Don't block */ +static DBusMessage * +mpris_call_method_and_block(DBusConnection *connection, DBusMessage *message) +{ + DBusPendingCall *pending = NULL; + + if (!dbus_connection_send_with_reply(connection, message, &pending, MPRIS_QUERY_TIMEOUT)) { + LOG_ERR("dbus: error: failed to allocate message object"); + return NULL; + } + + dbus_pending_call_block(pending); + dbus_message_unref(message); + + /* Handle and gracrfully return different error types + * ('org.freedesktop.DBus.Error.NotSupported' etc.)*/ + DBusMessage *reply = dbus_pending_call_steal_reply(pending); + if (dbus_message_get_type(reply) == DBUS_MESSAGE_TYPE_ERROR) { + const char *error_name = dbus_message_get_error_name(reply); + if (dbus_message_is_error(reply, error_name)) { + DBusError error = {0}; + LOG_ERR("Message call returned an error: %s", error_name); + if (dbus_set_error_from_message(&error, message)) { + LOG_ERR("Report: %s", error.message); + } + dbus_error_free(&error); + dbus_pending_call_unref(pending); + return NULL; + } + } + + dbus_pending_call_unref(pending); + return reply; +} + +__attribute__((unused)) static DBusMessage * +mpris_get_property(DBusConnection *connection, const char *bus_name, const char *interface, const char *property_name) +{ + assert(bus_name != NULL && strlen(bus_name) > 0); + assert(interface != NULL && strlen(interface) > 0); + assert(property_name != NULL && strlen(property_name) > 0); + + DBusMessage *message + = dbus_message_new_method_call(bus_name, MPRIS_PATH, MPRIS_DBUS_INTERFACE ".Properties", "Get"); + dbus_message_append_args(message, DBUS_TYPE_STRING, &interface, DBUS_TYPE_STRING, &property_name, + DBUS_TYPE_INVALID); + return mpris_call_method_and_block(connection, message); +} + +static char * +mpris_get_bus_name(DBusConnection *connection, const char *identity_name) +{ + assert(identity_name != NULL && strlen(identity_name) > 0); + + DBusMessage *message + = dbus_message_new_method_call(MPRIS_DBUS_BUS, MPRIS_DBUS_PATH, MPRIS_DBUS_INTERFACE, "ListNames"); + DBusMessage *reply = mpris_call_method_and_block(connection, message); + + if (reply == NULL) { + return NULL; + } + + DBusError error = {0}; + char **bus_names; + dbus_int32_t bus_count; + + dbus_error_init(&error); + if (!dbus_message_get_args(reply, &error, DBUS_TYPE_ARRAY, DBUS_TYPE_STRING, &bus_names, &bus_count, + DBUS_TYPE_INVALID)) { + LOG_ERR("%s", error.message); + dbus_error_free(&error); + return NULL; + } + + if (bus_count == 0) { + return NULL; + } + + char *string = NULL; + for (dbus_int32_t i = 0; i < bus_count; i++) { + if (strlen(bus_names[i]) < strlen(MPRIS_BUS ".") + strlen(identity_name)) { + continue; + } + + if (strncmp(bus_names[i] + strlen(MPRIS_BUS "."), identity_name, strlen(identity_name)) != 0) { + continue; + } + + string = strdup(bus_names[i]); + break; + } + + dbus_free_string_array(bus_names); + dbus_error_free(&error); + dbus_message_unref(reply); + + LOG_DBG("Found bus name: %s", string); + + return string; +} + +static bool +mpris_unwrap_iter(DBusMessageIter *iter, dbus_int32_t type, void *target) +{ + DBusMessageIter type_iter = {0}; + + assert(dbus_message_iter_get_arg_type(iter) == DBUS_TYPE_VARIANT); + dbus_message_iter_recurse(iter, &type_iter); + char *tmp = dbus_message_iter_get_signature(&type_iter); + (void)tmp; + assert(dbus_message_iter_get_arg_type(&type_iter) == type); + bool status = !dbus_message_iter_has_next(&type_iter); + + DBusBasicValue value = {0}; + switch (type) { + case DBUS_TYPE_STRING: + dbus_message_iter_get_basic(&type_iter, &value); + *((char **)target) = strdup(value.str); + break; + case DBUS_TYPE_DOUBLE: + dbus_message_iter_get_basic(&type_iter, &value); + *((double *)target) = value.dbl; + break; + case DBUS_TYPE_BOOLEAN: + dbus_message_iter_get_basic(&type_iter, &value); + *((bool *)target) = value.bool_val; + break; + case DBUS_TYPE_INT64: + dbus_message_iter_get_basic(&type_iter, &value); + *((int64_t *)target) = value.i64; + break; + default:; + char *signature = dbus_message_iter_get_signature(&type_iter); + LOG_WARN("Trying to unwrap unsupported type: %s", signature); + dbus_free(signature); + status = false; + } + + return status; +} + +static bool +mpris_unwrap_message(DBusMessage *message, dbus_int32_t type, void *target) +{ + assert(message != NULL); + DBusMessageIter iter = {0}; + dbus_message_iter_init(message, &iter); + return mpris_unwrap_iter(&iter, type, target); +} + +static bool +mpris_metadata_parse(const char *entry_name, DBusMessageIter *entry_iter, struct mpris_metadata *buffer) +{ + const char *string_value = NULL; + DBusMessageIter array_iter = {0}; + + if (strcmp(entry_name, "mpris:trackid") == 0) { + assert(dbus_message_iter_get_arg_type(entry_iter) == DBUS_TYPE_OBJECT_PATH); + dbus_message_iter_get_basic(entry_iter, &string_value); + buffer->trackid = strdup(string_value); + + } else if (strcmp(entry_name, "xesam:album") == 0) { + assert(dbus_message_iter_get_arg_type(entry_iter) == DBUS_TYPE_STRING); + dbus_message_iter_get_basic(entry_iter, &string_value); + buffer->album = strdup(string_value); + + } else if (strcmp(entry_name, "xesam:artist") == 0) { + /* TODO: Propertly format string arrays */ + /* NOTE: Currently, only the first artist will be shown, as we + * ignore the rest */ + assert(dbus_message_iter_get_arg_type(entry_iter) == DBUS_TYPE_ARRAY); + dbus_message_iter_recurse(entry_iter, &array_iter); + assert(dbus_message_iter_get_arg_type(&array_iter) == DBUS_TYPE_STRING); + + dbus_message_iter_get_basic(&array_iter, &string_value); + buffer->artists = strdup(string_value); + + } else if (strcmp(entry_name, "xesam:title") == 0) { + assert(dbus_message_iter_get_arg_type(entry_iter) == DBUS_TYPE_STRING); + dbus_message_iter_get_basic(entry_iter, &string_value); + buffer->title = strdup(string_value); + + } else if (strcmp(entry_name, "mpris:length") == 0) { + assert(dbus_message_iter_get_arg_type(entry_iter) == DBUS_TYPE_INT64); + dbus_message_iter_get_basic(entry_iter, &buffer->length_nsec); + } + + return true; +} + +static bool +mpris_unwrap_metadata_message(DBusMessage *message, struct mpris_metadata *metadata) +{ + bool status = true; + + /* Unpack values returned from DBus method calls */ + assert(dbus_message_get_type(message) == DBUS_MESSAGE_TYPE_METHOD_RETURN); + DBusMessageIter message_iter = {0}, outer_array_iter = {0}, array_iter = {0}; + dbus_message_iter_init(message, &message_iter); + assert(dbus_message_iter_get_arg_type(&message_iter) == DBUS_TYPE_VARIANT); + dbus_message_iter_recurse(&message_iter, &outer_array_iter); + assert(dbus_message_iter_get_arg_type(&outer_array_iter) == DBUS_TYPE_ARRAY); + dbus_message_iter_recurse(&outer_array_iter, &array_iter); + + dbus_int32_t current_type = 0; + while ((current_type = dbus_message_iter_get_arg_type(&array_iter)) != DBUS_TYPE_INVALID) { + assert(current_type == DBUS_TYPE_DICT_ENTRY); + + const char *entry_name = NULL; + DBusMessageIter entry_iter = {0}, entry_sub_iter = {0}; + + dbus_message_iter_recurse(&array_iter, &entry_iter); + assert(dbus_message_iter_get_arg_type(&entry_iter) == DBUS_TYPE_STRING); + dbus_message_iter_get_basic(&entry_iter, &entry_name); + + dbus_message_iter_next(&entry_iter); + dbus_message_iter_recurse(&entry_iter, &entry_sub_iter); + mpris_metadata_parse(entry_name, &entry_sub_iter, metadata); + + dbus_message_iter_next(&array_iter); + } + + return status; +} + +/* ------------- */ + +static void +mpris_clear(struct mpris_property *property) +{ + struct mpris_metadata *metadata = &property->metadata; + if (metadata->album != NULL) { + free(metadata->album); + } + if (metadata->artists != NULL) { + free(metadata->artists); + } + if (metadata->title != NULL) { + free(metadata->title); + } + + memset(property, 0, sizeof(*property)); +} + +static void +secs_to_str(unsigned secs, char *s, size_t sz) +{ + unsigned hours = secs / (60 * 60); + unsigned minutes = secs % (60 * 60) / 60; + secs %= 60; + + if (hours > 0) + snprintf(s, sz, "%02u:%02u:%02u", hours, minutes, secs); + else + snprintf(s, sz, "%02u:%02u", minutes, secs); +} + +static void +destroy(struct module *mod) +{ + struct private *m = mod->private; + dbus_connection_close(m->connection); + + free((void *)m->target_bus_name); + free((void *)m->target_identity); + + mpris_clear(&m->property); + + m->label->destroy(m->label); + + free(m); + module_default_destroy(mod); +} + +static const char * +description(const struct module *mod) +{ + return "mpris"; +} + +static struct exposable * +content(struct module *mod) +{ + const struct private *m = mod->private; + const struct mpris_metadata metadata = m->property.metadata; + + /* usec -> msec -> sec */ + uint32_t position_sec = m->property.position_usec / 1000 / 1000; + uint32_t length_sec = metadata.length_nsec / 1000 / 1000; + + char pos[16], end[16]; + secs_to_str(position_sec, pos, sizeof(pos)); + secs_to_str(length_sec, end, sizeof(end)); + + char *playback_str = NULL; + switch (m->property.playback_status) { + case MPRIS_PLAYBACK_STOPPED: + playback_str = "stopped"; + break; + case MPRIS_PLAYBACK_PLAYING: + playback_str = "playing"; + break; + case MPRIS_PLAYBACK_PAUSED: + playback_str = "paused"; + break; + case MPRIS_PLAYBACK_INVALID: + playback_str = "offline"; + } + + char *loop_str = NULL; + switch (m->property.loop_status) { + case MPRIS_LOOP_NONE: + loop_str = "none"; + break; + case MPRIS_LOOP_TRACK: + loop_str = "track"; + break; + case MPRIS_LOOP_PLAYLIST: + loop_str = "playlist"; + break; + default: + loop_str = ""; + break; + } + + uint32_t volume = (m->property.volume >= 0.995) ? 100 : 100 * m->property.volume; + + struct tag_set tags = { + .tags = (struct tag *[]){ + tag_new_string(mod, "state", playback_str), + tag_new_string(mod, "identity", m->target_identity), + /* Stay consistent with existing modules naming + * conventions (mpd)? */ + tag_new_bool(mod, "random", m->property.shuffle), + tag_new_string(mod, "loop", loop_str), + tag_new_int_range(mod, "volume", volume, 0, 100), + tag_new_string(mod, "album", metadata.album), + tag_new_string(mod, "artist", metadata.artists), + tag_new_string(mod, "title", metadata.title), + tag_new_string(mod, "pos", pos), + tag_new_string(mod, "end", end), + tag_new_int_realtime( + mod, "elapsed", position_sec, 0, length_sec, TAG_REALTIME_SECS), + }, + .count = 11, + }; + + mtx_unlock(&mod->lock); + + struct exposable *exposable = m->label->instantiate(m->label, &tags); + + tag_set_destroy(&tags); + return exposable; +} + +static bool +update_status(struct module *mod) +{ + struct private *m = mod->private; + mtx_lock(&mod->lock); + + /* Property: Metadata */ + mpris_clear(&m->property); + DBusMessage *message = mpris_get_property(m->connection, m->target_bus_name, MPRIS_INTERFACE_PLAYER, "Metadata"); + mpris_unwrap_metadata_message(message, &m->property.metadata); + dbus_message_unref(message); + + /* Update remaining properties */ + /* Property: PlaybackStatus */ + char *string = NULL; + message = mpris_get_property(m->connection, m->target_bus_name, MPRIS_INTERFACE_PLAYER, "PlaybackStatus"); + mpris_unwrap_message(message, DBUS_TYPE_STRING, &string); + if (strcmp(string, "Stopped")) { + m->property.playback_status = MPRIS_PLAYBACK_STOPPED; + } else if (strcmp(string, "Paused")) { + m->property.playback_status = MPRIS_PLAYBACK_PAUSED; + } else if (strcmp(string, "Playing")) { + m->property.playback_status = MPRIS_PLAYBACK_PLAYING; + } + dbus_message_unref(message); + + /* Property: LoopStatus */ + message = mpris_get_property(m->connection, m->target_bus_name, MPRIS_INTERFACE_PLAYER, "LoopStatus"); + mpris_unwrap_message(message, DBUS_TYPE_STRING, &string); + if (strcmp(string, "None")) { + m->property.loop_status = MPRIS_LOOP_NONE; + } else if (strcmp(string, "Track")) { + m->property.loop_status = MPRIS_LOOP_TRACK; + } else if (strcmp(string, "Playlist")) { + m->property.loop_status = MPRIS_LOOP_PLAYLIST; + } + dbus_message_unref(message); + + /* Property: Volume */ + message = mpris_get_property(m->connection, m->target_bus_name, MPRIS_INTERFACE_PLAYER, "Volume"); + mpris_unwrap_message(message, DBUS_TYPE_DOUBLE, &m->property.volume); + dbus_message_unref(message); + + /* Property: Rate */ + message = mpris_get_property(m->connection, m->target_bus_name, MPRIS_INTERFACE_PLAYER, "Rate"); + mpris_unwrap_message(message, DBUS_TYPE_DOUBLE, &m->property.rate); + dbus_message_unref(message); + + /* Property: Position */ + message = mpris_get_property(m->connection, m->target_bus_name, MPRIS_INTERFACE_PLAYER, "Position"); + mpris_unwrap_message(message, DBUS_TYPE_INT64, &m->property.position_usec); + dbus_message_unref(message); + + mtx_unlock(&mod->lock); + + return true; +} + +static int +run(struct module *mod) +{ + /*const struct private *m = mod->private;*/ + const struct bar *bar = mod->bar; + struct private *m = mod->private; + + DBusError error = {0}; + DBusConnection *connection = dbus_bus_get_private(DBUS_BUS_SESSION, &error); + if (dbus_error_is_set(&error)) { + LOG_ERR("Failed to connect to session bus: %s", error.message); + return -1; + } + m->connection = connection; + + int ret = 0; + bool aborted = false; + while (ret == 0 && !aborted) { + const uint32_t timeout_ms = 250; + + struct pollfd fds[] = {{.fd = mod->abort_fd, .events = POLLIN}}; + if (poll(fds, 1, timeout_ms) < 0) { + if (errno == EINTR) + continue; + + LOG_ERRNO("failed to poll"); + break; + } + + if (fds[0].revents & POLLIN) { + aborted = true; + break; + } + + /* TODO: Set up listener to catch disconnect events */ + if (m->target_bus_name == NULL) { + m->target_bus_name = mpris_get_bus_name(m->connection, "ncspot"); + if (m->target_bus_name == NULL) { + continue; + } + + /* TODO: This call might fail the the client does not + * respect the mpris spec */ + DBusMessage *message = mpris_get_property(m->connection, m->target_bus_name, MPRIS_INTERFACE, "Identity"); + mpris_unwrap_message(message, DBUS_TYPE_STRING, &m->target_identity); + LOG_DBG("Player identity: %s", m->target_identity); + } + + aborted = !update_status(mod); + bar->refresh(bar); + } + + return ret; +} + +struct refresh_context { + struct module *mod; + int abort_fd; + long milli_seconds; +}; + +static bool +refresh_in(struct module *mod, long milli_seconds) +{ + return true; +} + +static struct module * +mpris_new(const char *identity, struct particle *label) +{ + struct private *priv = calloc(1, sizeof(*priv)); + priv->label = label; + priv->target_identity = identity; + + struct module *mod = module_common_new(); + mod->private = priv; + mod->run = &run; + mod->destroy = &destroy; + mod->content = &content; + mod->refresh_in = &refresh_in; + mod->description = &description; + return mod; +} + +static struct module * +from_conf(const struct yml_node *node, struct conf_inherit inherited) +{ + const struct yml_node *identity = yml_get_value(node, "identity"); + const struct yml_node *c = yml_get_value(node, "content"); + + return mpris_new(yml_value_as_string(identity), conf_to_particle(c, inherited)); +} + +static bool +verify_conf(keychain_t *chain, const struct yml_node *node) +{ + // TODO: Add the ability to display the status of the most + // recently active player. This will require a listener. + static const struct attr_info attrs[] = { + {"identity", true, &conf_verify_string}, + MODULE_COMMON_ATTRS, + }; + + return conf_verify_dict(chain, node, attrs); +} + +const struct module_iface module_mpris_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_mpd_iface"))); +#endif diff --git a/plugin.c b/plugin.c index 8e75389..1e8ad3b 100644 --- a/plugin.c +++ b/plugin.c @@ -57,6 +57,9 @@ EXTERN_MODULE(mem); #if defined(HAVE_PLUGIN_mpd) EXTERN_MODULE(mpd); #endif +#if defined(HAVE_PLUGIN_mpris) +EXTERN_MODULE(mpris); +#endif #if defined(HAVE_PLUGIN_i3) EXTERN_MODULE(i3); #endif @@ -187,6 +190,9 @@ static void __attribute__((constructor)) init(void) #if defined(HAVE_PLUGIN_mpd) REGISTER_CORE_MODULE(mpd, mpd); #endif +#if defined(HAVE_PLUGIN_mpris) + REGISTER_CORE_MODULE(mpris, mpris); +#endif #if defined(HAVE_PLUGIN_i3) REGISTER_CORE_MODULE(i3, i3); #endif