From 1c7e4e79c59120e3aad092356d66f6e53e849e1d Mon Sep 17 00:00:00 2001 From: zydezu <50119098+zydezu@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:38:12 +0100 Subject: [PATCH] FEAT: add MPRIS for music attenuation support on linux --- runtime/cmake/PublicProducts.cmake | 10 +- runtime/include/music_attenuation.h | 5 +- runtime/src/music_attenuation.cpp | 258 ++++++++++++++++++++++++++++ runtime/src/settings_overlay.cpp | 4 +- 4 files changed, 270 insertions(+), 7 deletions(-) diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake index 580ca77..b9d1eac 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -80,7 +80,9 @@ target_link_libraries(mkw_runtime_common PRIVATE mkw::pugixml mkw::toml11 mkw::c if(WIN32) target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp) else() - target_link_libraries(mkw_runtime_common PRIVATE mkw::libco) + # ${CMAKE_DL_LIBS} for music_attenuation.cpp's dlopen of libdbus-1 (MPRIS + # media monitoring). Empty string on glibc >= 2.34 where dl* is in libc. + target_link_libraries(mkw_runtime_common PRIVATE mkw::libco ${CMAKE_DL_LIBS}) endif() if(MKW_CPPWINRT_INCLUDE_DIR) if(NOT EXISTS "${MKW_CPPWINRT_INCLUDE_DIR}/winrt/base.h") @@ -219,8 +221,10 @@ function(mkw_configure_product target) # target_link_libraries (object libraries don't carry usage requirements to a consumer # that isn't itself linked against as a target). fiber_manager.cpp's co_* calls live in # those objects, so the actual executable link needs mkw::libco directly, same as it - # needs it independently of that first `if(WIN32)` branch above. - target_link_libraries(${target} PRIVATE mkw::libco) + # needs it independently of that first `if(WIN32)` branch above. ${CMAKE_DL_LIBS} is + # here for the same reason: music_attenuation.cpp's dlopen(libdbus-1) lives in those + # objects (empty string on glibc >= 2.34, where dl* is in libc). + target_link_libraries(${target} PRIVATE mkw::libco ${CMAKE_DL_LIBS}) endif() if(WIN32) foreach(runtime_dll libc++.dll libunwind.dll) diff --git a/runtime/include/music_attenuation.h b/runtime/include/music_attenuation.h index f9b397e..f4a8b9a 100644 --- a/runtime/include/music_attenuation.h +++ b/runtime/include/music_attenuation.h @@ -4,8 +4,9 @@ namespace MusicAttenuation { -// Enables the optional Windows media-session integration. The monitor is -// started lazily the first time this is enabled. +// Enables the optional external-media integration (Windows media sessions, +// or MPRIS over D-Bus on Linux). The monitor is started lazily the first time +// this is enabled. void SetEnabled(bool enabled) noexcept; void SetMusicVolume(float volume) noexcept; void SetSoundEffectsVolume(float volume) noexcept; diff --git a/runtime/src/music_attenuation.cpp b/runtime/src/music_attenuation.cpp index 3b99941..3ce2b82 100644 --- a/runtime/src/music_attenuation.cpp +++ b/runtime/src/music_attenuation.cpp @@ -16,6 +16,11 @@ #include #include #include +#elif defined(__linux__) +#include + +#include +#include #endif namespace MusicAttenuation { @@ -191,6 +196,256 @@ void MonitorWindowsMediaSessions() noexcept { << error.message().c_str() << std::endl; } } +#elif defined(__linux__) + +// For linux - watches MPRIS players on the D-Bus session bus and drives the +// same g_externalMediaPlaying / g_mediaControl* atomics. +// +// libdbus-1 is loaded with dlopen rather than linked so a machine with no +// session bus (headless, D-Bus absent, some containers) can still run the game, +// here, the monitor reports "unavailable" like the Windows does +// when the media-session API is missing. + +extern "C" { + +struct MprisDBusConnection; +struct MprisDBusMessage; + +// Mirrors DBusError from dbus-errors.h (frozen public layout). +struct MprisDBusError { + const char* name; + const char* message; + unsigned int dummy1 : 1; + unsigned int dummy2 : 1; + unsigned int dummy3 : 1; + unsigned int dummy4 : 1; + unsigned int dummy5 : 1; + void* padding1; +}; + +// Mirrors DBusMessageIter from dbus-message.h +struct MprisDBusIter { + void* dummy1; + void* dummy2; + uint32_t dummy3; + int dummy4; + int dummy5; + int dummy6; + int dummy7; + int dummy8; + int dummy9; + int dummy10; + int dummy11; + int pad1; + int pad2; + void* pad3; +}; + +} // extern "C" + +constexpr int kMprisBusSession = 0; // DBUS_BUS_SESSION +constexpr int kMprisTypeInvalid = 0; // DBUS_TYPE_INVALID +constexpr int kMprisTypeString = 's'; // DBUS_TYPE_STRING +constexpr int kMprisTypeArray = 'a'; // DBUS_TYPE_ARRAY +constexpr int kMprisTypeVariant = 'v';// DBUS_TYPE_VARIANT +// Short blocking timeout so a wedged player can never stall the poll thread for +// the full libdbus default (25s). +constexpr int kMprisSendTimeoutMs = 500; +constexpr char kMprisNamePrefix[] = "org.mpris.MediaPlayer2."; + +struct MprisDBus { + void* handle = nullptr; + + MprisDBusConnection* (*bus_get_private)(int, MprisDBusError*) = nullptr; + void (*connection_close)(MprisDBusConnection*) = nullptr; + void (*connection_unref)(MprisDBusConnection*) = nullptr; + void (*connection_set_exit_on_disconnect)(MprisDBusConnection*, unsigned int) = nullptr; + void (*error_init)(MprisDBusError*) = nullptr; + void (*error_free)(MprisDBusError*) = nullptr; + MprisDBusMessage* (*message_new_method_call)(const char*, const char*, const char*, + const char*) = nullptr; + unsigned int (*message_append_args)(MprisDBusMessage*, int, ...) = nullptr; + MprisDBusMessage* (*send_with_reply_and_block)(MprisDBusConnection*, MprisDBusMessage*, int, + MprisDBusError*) = nullptr; + void (*message_unref)(MprisDBusMessage*) = nullptr; + unsigned int (*message_iter_init)(MprisDBusMessage*, MprisDBusIter*) = nullptr; + unsigned int (*message_iter_next)(MprisDBusIter*) = nullptr; + void (*message_iter_recurse)(MprisDBusIter*, MprisDBusIter*) = nullptr; + int (*message_iter_get_arg_type)(MprisDBusIter*) = nullptr; + void (*message_iter_get_basic)(MprisDBusIter*, void*) = nullptr; + + template + bool Bind(Fn& fn, const char* symbol) noexcept { + fn = reinterpret_cast(dlsym(handle, symbol)); + return fn != nullptr; + } + + bool Load() noexcept { + handle = dlopen("libdbus-1.so.3", RTLD_NOW | RTLD_LOCAL); + if (handle == nullptr) { + return false; + } + const bool ok = + Bind(bus_get_private, "dbus_bus_get_private") && + Bind(connection_close, "dbus_connection_close") && + Bind(connection_unref, "dbus_connection_unref") && + Bind(connection_set_exit_on_disconnect, "dbus_connection_set_exit_on_disconnect") && + Bind(error_init, "dbus_error_init") && + Bind(error_free, "dbus_error_free") && + Bind(message_new_method_call, "dbus_message_new_method_call") && + Bind(message_append_args, "dbus_message_append_args") && + Bind(send_with_reply_and_block, "dbus_connection_send_with_reply_and_block") && + Bind(message_unref, "dbus_message_unref") && + Bind(message_iter_init, "dbus_message_iter_init") && + Bind(message_iter_next, "dbus_message_iter_next") && + Bind(message_iter_recurse, "dbus_message_iter_recurse") && + Bind(message_iter_get_arg_type, "dbus_message_iter_get_arg_type") && + Bind(message_iter_get_basic, "dbus_message_iter_get_basic"); + if (!ok) { + dlclose(handle); + handle = nullptr; + return false; + } + return true; + } + + // org.freedesktop.DBus.ListNames + bool ListNames(MprisDBusConnection* conn, std::vector& out) noexcept { + MprisDBusMessage* msg = message_new_method_call( + "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames"); + if (msg == nullptr) { + return false; + } + MprisDBusError err; + error_init(&err); + MprisDBusMessage* reply = send_with_reply_and_block(conn, msg, kMprisSendTimeoutMs, &err); + message_unref(msg); + if (reply == nullptr) { + error_free(&err); + return false; + } + error_free(&err); + + MprisDBusIter iter; + MprisDBusIter sub; + if (message_iter_init(reply, &iter) && + message_iter_get_arg_type(&iter) == kMprisTypeArray) { + message_iter_recurse(&iter, &sub); + while (message_iter_get_arg_type(&sub) == kMprisTypeString) { + const char* name = nullptr; + message_iter_get_basic(&sub, &name); + if (name != nullptr) { + out.emplace_back(name); + } + message_iter_next(&sub); + } + } + message_unref(reply); + return true; + } + + // org.freedesktop.DBus.Properties.Get(Player, "PlaybackStatus") for one + // MPRIS player - the reply is a variant wrapping a string (eg: "Playing"). + bool PlaybackStatus(MprisDBusConnection* conn, const char* busName, std::string& out) noexcept { + MprisDBusMessage* msg = message_new_method_call( + busName, "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get"); + if (msg == nullptr) { + return false; + } + const char* iface = "org.mpris.MediaPlayer2.Player"; + const char* prop = "PlaybackStatus"; + if (!message_append_args(msg, kMprisTypeString, &iface, kMprisTypeString, &prop, + kMprisTypeInvalid)) { + message_unref(msg); + return false; + } + MprisDBusError err; + error_init(&err); + MprisDBusMessage* reply = send_with_reply_and_block(conn, msg, kMprisSendTimeoutMs, &err); + message_unref(msg); + if (reply == nullptr) { + error_free(&err); + return false; + } + error_free(&err); + + bool haveValue = false; + MprisDBusIter iter; + MprisDBusIter variant; + if (message_iter_init(reply, &iter) && + message_iter_get_arg_type(&iter) == kMprisTypeVariant) { + message_iter_recurse(&iter, &variant); + if (message_iter_get_arg_type(&variant) == kMprisTypeString) { + const char* value = nullptr; + message_iter_get_basic(&variant, &value); + if (value != nullptr) { + out.assign(value); + haveValue = true; + } + } + } + message_unref(reply); + return haveValue; + } +}; + +void MonitorLinuxMprisSessions() noexcept { + using namespace std::chrono_literals; + + MprisDBus dbus; + if (!dbus.Load()) { + g_mediaControlAvailable.store(false, std::memory_order_release); + g_externalMediaPlaying.store(false, std::memory_order_release); + g_mediaControlInitializationComplete.store(true, std::memory_order_release); + std::cerr << "[audio] MPRIS media monitoring unavailable: libdbus-1 could not be loaded" + << std::endl; + return; + } + + MprisDBusError err; + dbus.error_init(&err); + MprisDBusConnection* conn = dbus.bus_get_private(kMprisBusSession, &err); + if (conn == nullptr) { + g_mediaControlAvailable.store(false, std::memory_order_release); + g_externalMediaPlaying.store(false, std::memory_order_release); + g_mediaControlInitializationComplete.store(true, std::memory_order_release); + std::cerr << "[audio] MPRIS media monitoring unavailable: " + << (err.message != nullptr ? err.message : "no D-Bus session bus") << std::endl; + dbus.error_free(&err); + return; + } + dbus.error_free(&err); + // A private bus connection defaults to terminating the process when the bus drops. + dbus.connection_set_exit_on_disconnect(conn, 0u); + + for (bool firstPoll = true;; firstPoll = false) { + bool playing = false; + std::vector names; + const bool queried = dbus.ListNames(conn, names); + if (queried) { + for (const auto& name : names) { + if (name.compare(0, sizeof(kMprisNamePrefix) - 1, kMprisNamePrefix) != 0) { + continue; + } + std::string status; + if (dbus.PlaybackStatus(conn, name.c_str(), status) && status == "Playing") { + playing = true; + break; + } + } + } + g_externalMediaPlaying.store(playing, std::memory_order_release); + g_mediaControlAvailable.store(queried, std::memory_order_release); + if (firstPoll) { + g_mediaControlInitializationComplete.store(true, std::memory_order_release); + if (!queried) { + std::cerr << "[audio] MPRIS media monitoring unavailable: session bus query failed" + << std::endl; + } + } + std::this_thread::sleep_for(250ms); + } +} #endif void StartMonitor() noexcept { @@ -198,6 +453,9 @@ void StartMonitor() noexcept { // The process owns this monitor for its remaining lifetime. Keeping it // detached avoids shutdown ordering between WinRT and static audio state. std::thread(MonitorWindowsMediaSessions).detach(); +#elif defined(__linux__) + // Detached so there is no shutdown ordering to manage against static audio state. + std::thread(MonitorLinuxMprisSessions).detach(); #else g_mediaControlAvailable.store(false, std::memory_order_release); g_mediaControlInitializationComplete.store(true, std::memory_order_release); diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 1f3895e..a48415d 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -547,9 +547,9 @@ void DrawAudioSettings() { if (MusicAttenuation::IsExternalMediaPlaying()) { ImGui::TextDisabled("External media is playing; game music is muted."); } else if (!MusicAttenuation::IsMediaControlInitializationComplete()) { - ImGui::TextDisabled("Waiting for Windows Media Control..."); + ImGui::TextDisabled("Waiting for media controls..."); } else if (!MusicAttenuation::IsMediaControlAvailable()) { - ImGui::TextDisabled("Windows Media Control is unavailable."); + ImGui::TextDisabled("Media controls are unavailable."); } else { ImGui::TextDisabled("No external media is currently playing."); }