diff --git a/include/factory.hpp b/include/factory.hpp index d69930f9..688b9acc 100644 --- a/include/factory.hpp +++ b/include/factory.hpp @@ -41,6 +41,9 @@ #ifdef HAVE_DBUSMENU #include "modules/sni/tray.hpp" #endif +#ifdef HAVE_MPRIS +#include "modules/mpris/mpris.hpp" +#endif #ifdef HAVE_LIBNL #include "modules/network.hpp" #endif diff --git a/include/modules/mpris/mpris.hpp b/include/modules/mpris/mpris.hpp new file mode 100644 index 00000000..4f8ddb16 --- /dev/null +++ b/include/modules/mpris/mpris.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include + +#include "gtkmm/box.h" +#include "gtkmm/label.h" + +extern "C" { +#include +} + +#include "ALabel.hpp" +#include "util/sleeper_thread.hpp" + +namespace waybar::modules::mpris { + +class Mpris : public AModule { + public: + Mpris(const std::string&, const Json::Value&); + ~Mpris(); + auto update() -> void; + bool handleToggle(GdkEventButton* const&); + + private: + static auto onPlayerNameAppeared(PlayerctlPlayerManager*, PlayerctlPlayerName*, gpointer) -> void; + static auto onPlayerNameVanished(PlayerctlPlayerManager*, PlayerctlPlayerName*, gpointer) -> void; + static auto onPlayerPlay(PlayerctlPlayer*, gpointer) -> void; + static auto onPlayerPause(PlayerctlPlayer*, gpointer) -> void; + static auto onPlayerStop(PlayerctlPlayer*, gpointer) -> void; + static auto onPlayerMetadata(PlayerctlPlayer*, GVariant*, gpointer) -> void; + + struct PlayerInfo { + std::string name; + PlayerctlPlaybackStatus status; + std::string status_string; + + std::optional artist; + std::optional album; + std::optional title; + std::optional length; // as HH:MM:SS + }; + + auto getPlayerInfo() -> std::optional; + auto getIcon(const Json::Value&, const std::string&) -> std::string; + + Gtk::Box box_; + Gtk::Label label_; + + // config + std::string format_; + std::string format_playing_; + std::string format_paused_; + std::string format_stopped_; + std::chrono::seconds interval_; + std::string player_; + std::vector ignored_players_; + + PlayerctlPlayerManager* manager; + PlayerctlPlayer* player; + std::string lastStatus; + std::string lastPlayer; + + util::SleeperThread thread_; +}; + +} // namespace waybar::modules::mpris diff --git a/man/waybar-mpris.5.scd b/man/waybar-mpris.5.scd new file mode 100644 index 00000000..d2a72d98 --- /dev/null +++ b/man/waybar-mpris.5.scd @@ -0,0 +1,103 @@ +waybar-mpris(5) + +# NAME + +waybar - MPRIS module + +# DESCRIPTION + +The *mpris* module displays currently playing media via libplayerctl. + +# CONFIGURATION + +*player*: ++ + typeof: string ++ + default: playerctld ++ + Name of the MPRIS player to attach to. Using the default value always + follows the currenly active player. + +*ignored-players*: ++ + typeof: []string ++ + Ignore updates of the listed players, when using playerctld. + +*interval*: ++ + typeof: integer ++ + Refresh MPRIS information on a timer. + +*format*: ++ + typeof: string ++ + default: {player} ({status}) {dynamic} ++ + The text format. + +*format-[status]*: ++ + typeof: string ++ + The status-specific text format. + +*on-click*: ++ + typeof: string ++ + default: play-pause ++ + Overwrite default action toggles. + +*on-middle-click*: ++ + typeof: string ++ + default: previous track ++ + Overwrite default action toggles. + +*on-right-click*: ++ + typeof: string ++ + default: next track ++ + Overwrite default action toggles. + +*player-icons*: ++ + typeof: map[string]string + Allows setting _{player-icon}_ based on player-name property. + +*status-icons*: ++ + typeof: map[string]string + Allows setting _{status-icon}_ based on player status (playing, paused, + stopped). + + +# FORMAT REPLACEMENTS + +*{player}*: The name of the current media player + +*{status}*: The current status (playing, paused, stopped) + +*{artist}*: The artist of the current track + +*{album}*: The album title of the current track + +*{title}*: The title of the current track + +*{length}*: Length of the track, formatted as HH:MM:SS + +*{dynamic}*: Use _{artist}_, _{album}_, _{title}_ and _{length}_, automatically omit++ + empty values + +*{player-icon}*: Chooses an icon from _player-icons_ based on _{player}_ + +*{status-icon}*: Chooses an icon from _status-icons_ based on _{status}_ + +# EXAMPLES + +``` +"mpris": { + "format": "DEFAULT: {player_icon} {dynamic}", + "format-paused": "DEFAULT: {status_icon} {dynamic}", + "player-icons": { + "default": "▶", + "mpv": "🎵" + }, + "status-icons": { + "paused": "⏸" + }, + // "ignored-players": ["firefox"] +} +``` + +# STYLE + +- *#mpris* +- *#mpris.${status}* +- *#mpris.${player}* diff --git a/man/waybar.5.scd.in b/man/waybar.5.scd.in index 54340f21..b1ed4c52 100644 --- a/man/waybar.5.scd.in +++ b/man/waybar.5.scd.in @@ -266,6 +266,7 @@ A module group is defined by specifying a module named "group/some-group-name". - *waybar-keyboard-state(5)* - *waybar-memory(5)* - *waybar-mpd(5)* +- *waybar-mpris(5)* - *waybar-network(5)* - *waybar-pulseaudio(5)* - *waybar-river-mode(5)* diff --git a/meson.build b/meson.build index 557a02dc..83f5998f 100644 --- a/meson.build +++ b/meson.build @@ -86,7 +86,10 @@ wayland_cursor = dependency('wayland-cursor') wayland_protos = dependency('wayland-protocols') gtkmm = dependency('gtkmm-3.0', version : ['>=3.22.0']) dbusmenu_gtk = dependency('dbusmenu-gtk3-0.4', required: get_option('dbusmenu-gtk')) -giounix = dependency('gio-unix-2.0', required: (get_option('dbusmenu-gtk').enabled() or get_option('logind').enabled() or get_option('upower_glib').enabled())) +giounix = dependency('gio-unix-2.0', required: (get_option('dbusmenu-gtk').enabled() or + get_option('logind').enabled() or + get_option('upower_glib').enabled() or + get_option('mpris').enabled())) jsoncpp = dependency('jsoncpp', version : ['>=1.9.2'], fallback : ['jsoncpp', 'jsoncpp_dep']) sigcpp = dependency('sigc++-2.0') libinotify = dependency('libinotify', required: false) @@ -95,6 +98,7 @@ libinput = dependency('libinput', required: get_option('libinput')) libnl = dependency('libnl-3.0', required: get_option('libnl')) libnlgen = dependency('libnl-genl-3.0', required: get_option('libnl')) upower_glib = dependency('upower-glib', required: get_option('upower_glib')) +playerctl = dependency('playerctl', version : ['>=2.0.0'], required: get_option('mpris')) libpulse = dependency('libpulse', required: get_option('pulseaudio')) libudev = dependency('libudev', required: get_option('libudev')) libevdev = dependency('libevdev', required: get_option('libevdev')) @@ -238,6 +242,11 @@ if (upower_glib.found() and giounix.found() and not get_option('logind').disable src_files += 'src/modules/upower/upower_tooltip.cpp' endif +if (playerctl.found() and giounix.found() and not get_option('logind').disabled()) + add_project_arguments('-DHAVE_MPRIS', language: 'cpp') + src_files += 'src/modules/mpris/mpris.cpp' +endif + if libpulse.found() add_project_arguments('-DHAVE_LIBPULSE', language: 'cpp') src_files += 'src/modules/pulseaudio.cpp' @@ -334,6 +343,7 @@ executable( libnl, libnlgen, upower_glib, + playerctl, libpulse, libjack, libwireplumber, @@ -387,6 +397,7 @@ if scdoc.found() 'waybar-keyboard-state.5.scd', 'waybar-memory.5.scd', 'waybar-mpd.5.scd', + 'waybar-mpris.5.scd', 'waybar-network.5.scd', 'waybar-pulseaudio.5.scd', 'waybar-river-mode.5.scd', diff --git a/meson_options.txt b/meson_options.txt index 402912f4..98cd4949 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -5,6 +5,7 @@ option('libudev', type: 'feature', value: 'auto', description: 'Enable libudev s option('libevdev', type: 'feature', value: 'auto', description: 'Enable libevdev support for evdev related features') option('pulseaudio', type: 'feature', value: 'auto', description: 'Enable support for pulseaudio') option('upower_glib', type: 'feature', value: 'auto', description: 'Enable support for upower') +option('mpris', type: 'feature', value: 'auto', description: 'Enable support for mpris') option('systemd', type: 'feature', value: 'auto', description: 'Install systemd user service unit') option('dbusmenu-gtk', type: 'feature', value: 'auto', description: 'Enable support for tray') option('man-pages', type: 'feature', value: 'auto', description: 'Generate and install man pages') diff --git a/src/factory.cpp b/src/factory.cpp index d16cb523..3ccf2581 100644 --- a/src/factory.cpp +++ b/src/factory.cpp @@ -22,6 +22,11 @@ waybar::AModule* waybar::Factory::makeModule(const std::string& name) const { return new waybar::modules::upower::UPower(id, config_[name]); } #endif +#ifdef HAVE_MPRIS + if (ref == "mpris") { + return new waybar::modules::mpris::Mpris(id, config_[name]); + } +#endif #ifdef HAVE_SWAY if (ref == "sway/mode") { return new waybar::modules::sway::Mode(id, config_[name]); diff --git a/src/modules/mpris/mpris.cpp b/src/modules/mpris/mpris.cpp new file mode 100644 index 00000000..651dfd51 --- /dev/null +++ b/src/modules/mpris/mpris.cpp @@ -0,0 +1,394 @@ +#include "modules/mpris/mpris.hpp" + +#include + +#include +#include +#include + +extern "C" { +#include +} + +#include + +namespace waybar::modules::mpris { + +const std::string DEFAULT_FORMAT = "{player} ({status}): {dynamic}"; + +Mpris::Mpris(const std::string& id, const Json::Value& config) + : AModule(config, "mpris", id), + box_(Gtk::ORIENTATION_HORIZONTAL, 0), + label_(), + format_(DEFAULT_FORMAT), + interval_(0), + player_("playerctld"), + manager(), + player() { + box_.pack_start(label_); + box_.set_name(name_); + event_box_.add(box_); + event_box_.signal_button_press_event().connect(sigc::mem_fun(*this, &Mpris::handleToggle)); + + if (config_["format"].isString()) { + format_ = config_["format"].asString(); + } + if (config_["format-playing"].isString()) { + format_playing_ = config_["format-playing"].asString(); + } + if (config_["format-paused"].isString()) { + format_paused_ = config_["format-paused"].asString(); + } + if (config_["format-stopped"].isString()) { + format_stopped_ = config_["format-stopped"].asString(); + } + if (config_["interval"].isUInt()) { + interval_ = std::chrono::seconds(config_["interval"].asUInt()); + } + if (config_["player"].isString()) { + player_ = config_["player"].asString(); + } + if (config_["ignored-players"].isArray()) { + for (auto it = config_["ignored-players"].begin(); it != config_["ignored-players"].end(); + ++it) { + ignored_players_.push_back(it->asString()); + } + } + + GError* error = nullptr; + manager = playerctl_player_manager_new(&error); + if (error) { + throw std::runtime_error(fmt::format("unable to create MPRIS client: {}", error->message)); + } + + g_object_connect(manager, "signal::name-appeared", G_CALLBACK(onPlayerNameAppeared), this, NULL); + g_object_connect(manager, "signal::name-vanished", G_CALLBACK(onPlayerNameVanished), this, NULL); + + if (player_ == "playerctld") { + // use playerctld proxy + PlayerctlPlayerName name = { + .instance = (gchar*)player_.c_str(), + .source = PLAYERCTL_SOURCE_DBUS_SESSION, + }; + player = playerctl_player_new_from_name(&name, &error); + + } else { + GList* players = playerctl_list_players(&error); + if (error) { + auto e = fmt::format("unable to list players: {}", error->message); + g_error_free(error); + throw std::runtime_error(e); + } + + for (auto p = players; p != NULL; p = p->next) { + auto pn = static_cast(p->data); + if (strcmp(pn->name, player_.c_str()) == 0) { + player = playerctl_player_new_from_name(pn, &error); + break; + } + } + } + + if (error) { + throw std::runtime_error( + fmt::format("unable to connect to player {}: {}", player_, error->message)); + } + + if (player) { + g_object_connect(player, "signal::play", G_CALLBACK(onPlayerPlay), this, "signal::pause", + G_CALLBACK(onPlayerPause), this, "signal::stop", G_CALLBACK(onPlayerStop), + this, "signal::stop", G_CALLBACK(onPlayerStop), this, "signal::metadata", + G_CALLBACK(onPlayerMetadata), this, NULL); + } + + // allow setting an interval count that triggers periodic refreshes + if (interval_.count() > 0) { + thread_ = [this] { + dp.emit(); + thread_.sleep_for(interval_); + }; + } + + // trigger initial update + dp.emit(); +} + +Mpris::~Mpris() { + if (manager != NULL) g_object_unref(manager); + if (player != NULL) g_object_unref(player); +} + +auto Mpris::getIcon(const Json::Value& icons, const std::string& key) -> std::string { + if (icons.isObject()) { + if (icons[key].isString()) { + return icons[key].asString(); + } else if (icons["default"].isString()) { + return icons["default"].asString(); + } + } + return ""; +} + +auto Mpris::onPlayerNameAppeared(PlayerctlPlayerManager* manager, PlayerctlPlayerName* player_name, + gpointer data) -> void { + Mpris* mpris = static_cast(data); + if (!mpris) return; + + spdlog::debug("mpris: name-appeared callback: {}", player_name->name); + + if (std::string(player_name->name) != mpris->player_) { + return; + } + + GError* error = nullptr; + mpris->player = playerctl_player_new_from_name(player_name, &error); + g_object_connect(mpris->player, "signal::play", G_CALLBACK(onPlayerPlay), mpris, "signal::pause", + G_CALLBACK(onPlayerPause), mpris, "signal::stop", G_CALLBACK(onPlayerStop), + mpris, "signal::stop", G_CALLBACK(onPlayerStop), mpris, "signal::metadata", + G_CALLBACK(onPlayerMetadata), mpris, NULL); + + mpris->dp.emit(); +} + +auto Mpris::onPlayerNameVanished(PlayerctlPlayerManager* manager, PlayerctlPlayerName* player_name, + gpointer data) -> void { + Mpris* mpris = static_cast(data); + if (!mpris) return; + + spdlog::debug("mpris: player-vanished callback: {}", player_name->name); + + if (std::string(player_name->name) == mpris->player_) { + mpris->player = nullptr; + mpris->dp.emit(); + } +} + +auto Mpris::onPlayerPlay(PlayerctlPlayer* player, gpointer data) -> void { + Mpris* mpris = static_cast(data); + if (!mpris) return; + + spdlog::debug("mpris: player-play callback"); + // update widget + mpris->dp.emit(); +} + +auto Mpris::onPlayerPause(PlayerctlPlayer* player, gpointer data) -> void { + Mpris* mpris = static_cast(data); + if (!mpris) return; + + spdlog::debug("mpris: player-pause callback"); + // update widget + mpris->dp.emit(); +} + +auto Mpris::onPlayerStop(PlayerctlPlayer* player, gpointer data) -> void { + Mpris* mpris = static_cast(data); + if (!mpris) return; + + spdlog::debug("mpris: player-stop callback"); + + // hide widget + mpris->event_box_.set_visible(false); + // update widget + mpris->dp.emit(); +} + +auto Mpris::onPlayerMetadata(PlayerctlPlayer* player, GVariant* metadata, gpointer data) -> void { + Mpris* mpris = static_cast(data); + if (!mpris) return; + + spdlog::debug("mpris: player-metadata callback"); + // update widget + mpris->dp.emit(); +} + +auto Mpris::getPlayerInfo() -> std::optional { + if (!player) { + return std::nullopt; + } + + GError* error = nullptr; + + char* player_status = nullptr; + auto player_playback_status = PLAYERCTL_PLAYBACK_STATUS_STOPPED; + g_object_get(player, "status", &player_status, "playback-status", &player_playback_status, NULL); + + std::string player_name = player_; + if (player_name == "playerctld") { + GList* players = playerctl_list_players(&error); + if (error) { + auto e = fmt::format("unable to list players: {}", error->message); + g_error_free(error); + throw std::runtime_error(e); + } + // > get the list of players [..] in order of activity + // https://github.com/altdesktop/playerctl/blob/b19a71cb9dba635df68d271bd2b3f6a99336a223/playerctl/playerctl-common.c#L248-L249 + players = g_list_first(players); + if (players) player_name = static_cast(players->data)->name; + } + + if (std::any_of(ignored_players_.begin(), ignored_players_.end(), + [&](const std::string& pn) { return player_name == pn; })) { + spdlog::warn("mpris[{}]: ignoring player update", player_name); + return std::nullopt; + } + + // make status lowercase + player_status[0] = std::tolower(player_status[0]); + + PlayerInfo info = { + .name = player_name, + .status = player_playback_status, + .status_string = player_status, + }; + + if (auto artist_ = playerctl_player_get_artist(player, &error)) { + spdlog::debug("mpris[{}]: artist = {}", info.name, artist_); + info.artist = Glib::Markup::escape_text(artist_); + g_free(artist_); + } + if (error) goto errorexit; + + if (auto album_ = playerctl_player_get_album(player, &error)) { + spdlog::debug("mpris[{}]: album = {}", info.name, album_); + info.album = Glib::Markup::escape_text(album_); + g_free(album_); + } + if (error) goto errorexit; + + if (auto title_ = playerctl_player_get_title(player, &error)) { + spdlog::debug("mpris[{}]: title = {}", info.name, title_); + info.title = Glib::Markup::escape_text(title_); + g_free(title_); + } + if (error) goto errorexit; + + if (auto length_ = playerctl_player_print_metadata_prop(player, "mpris:length", &error)) { + spdlog::debug("mpris[{}]: mpris:length = {}", info.name, length_); + std::chrono::microseconds len = std::chrono::microseconds(std::strtol(length_, nullptr, 10)); + auto len_h = std::chrono::duration_cast(len); + auto len_m = std::chrono::duration_cast(len - len_h); + auto len_s = std::chrono::duration_cast(len - len_m); + info.length = fmt::format("{:02}:{:02}:{:02}", len_h.count(), len_m.count(), len_s.count()); + g_free(length_); + } + if (error) goto errorexit; + + return info; + +errorexit: + spdlog::error("mpris[{}]: {}", info.name, error->message); + g_error_free(error); + return std::nullopt; +} + +bool Mpris::handleToggle(GdkEventButton* const& e) { + GError* error = nullptr; + + auto info = getPlayerInfo(); + if (!info) return false; + + if (e->type == GdkEventType::GDK_BUTTON_PRESS) { + switch (e->button) { + case 1: // left-click + if (config_["on-click"].isString()) { + return AModule::handleToggle(e); + } + playerctl_player_play_pause(player, &error); + break; + case 2: // middle-click + if (config_["on-middle-click"].isString()) { + return AModule::handleToggle(e); + } + playerctl_player_previous(player, &error); + break; + case 3: // right-click + if (config_["on-right-click"].isString()) { + return AModule::handleToggle(e); + } + playerctl_player_next(player, &error); + break; + } + } + if (error) { + spdlog::error("mpris[{}]: error running builtin on-click action: {}", (*info).name, + error->message); + g_error_free(error); + return false; + } + return true; +} + +auto Mpris::update() -> void { + auto opt = getPlayerInfo(); + if (!opt) { + event_box_.set_visible(false); + AModule::update(); + return; + } + auto info = *opt; + + if (info.status == PLAYERCTL_PLAYBACK_STATUS_STOPPED) { + spdlog::debug("mpris[{}]: player stopped, skipping update", info.name); + return; + } + + spdlog::debug("mpris[{}]: running update", info.name); + + // dynamic is the auto-formatted string containing a nice out-of-the-box + // format text + std::stringstream dynamic; + if (info.artist) dynamic << *info.artist << " - "; + if (info.album) dynamic << *info.album << " - "; + if (info.title) dynamic << *info.title; + if (info.length) + dynamic << " " + << "" + << "[" << *info.length << "]" + << ""; + + // set css class for player status + if (!lastStatus.empty() && box_.get_style_context()->has_class(lastStatus)) { + box_.get_style_context()->remove_class(lastStatus); + } + if (!box_.get_style_context()->has_class(info.status_string)) { + box_.get_style_context()->add_class(info.status_string); + } + lastStatus = info.status_string; + + // set css class for player name + if (!lastPlayer.empty() && box_.get_style_context()->has_class(lastPlayer)) { + box_.get_style_context()->remove_class(lastPlayer); + } + if (!box_.get_style_context()->has_class(info.name)) { + box_.get_style_context()->add_class(info.name); + } + lastPlayer = info.name; + + auto formatstr = format_; + switch (info.status) { + case PLAYERCTL_PLAYBACK_STATUS_PLAYING: + if (!format_playing_.empty()) formatstr = format_playing_; + break; + case PLAYERCTL_PLAYBACK_STATUS_PAUSED: + if (!format_paused_.empty()) formatstr = format_paused_; + break; + case PLAYERCTL_PLAYBACK_STATUS_STOPPED: + if (!format_stopped_.empty()) formatstr = format_stopped_; + break; + } + auto label_format = + fmt::format(formatstr, fmt::arg("player", info.name), fmt::arg("status", info.status_string), + fmt::arg("artist", *info.artist), fmt::arg("title", *info.title), + fmt::arg("album", *info.album), fmt::arg("length", *info.length), + fmt::arg("dynamic", dynamic.str()), + fmt::arg("player_icon", getIcon(config_["player-icons"], info.name)), + fmt::arg("status_icon", getIcon(config_["status-icons"], info.status_string))); + label_.set_markup(label_format); + + event_box_.set_visible(true); + // call parent update + AModule::update(); +} + +} // namespace waybar::modules::mpris