From fe717f06d32a6531b277a1d30dc6e186e4fe8210 Mon Sep 17 00:00:00 2001 From: ILA-dd Date: Thu, 10 Sep 2026 17:13:09 +0500 Subject: [PATCH 1/2] Fix Linux build with GCC --- .../components/tclient/menus_tclient.cpp | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/game/client/components/tclient/menus_tclient.cpp b/src/game/client/components/tclient/menus_tclient.cpp index 8c7a7d2..eeb7150 100644 --- a/src/game/client/components/tclient/menus_tclient.cpp +++ b/src/game/client/components/tclient/menus_tclient.cpp @@ -70,19 +70,19 @@ typedef struct static float s_Time = 0.0f; static bool s_StartedTime = false; -const float FontSize = 14.0f; -const float EditBoxFontSize = 12.0f; -const float LineSize = 20.0f; -const float ColorPickerLineSize = 25.0f; -const float HeadlineFontSize = 20.0f; -const float StandardFontSize = 14.0f; - -const float HeadlineHeight = HeadlineFontSize + 0.0f; -const float Margin = 10.0f; -const float MarginSmall = 5.0f; -const float MarginExtraSmall = 2.5f; -const float MarginBetweenSections = 30.0f; -const float MarginBetweenViews = 30.0f; +constexpr float FontSize = 14.0f; +constexpr float EditBoxFontSize = 12.0f; +constexpr float LineSize = 20.0f; +constexpr float ColorPickerLineSize = 25.0f; +constexpr float HeadlineFontSize = 20.0f; +constexpr float StandardFontSize = 14.0f; + +constexpr float HeadlineHeight = HeadlineFontSize + 0.0f; +constexpr float Margin = 10.0f; +constexpr float MarginSmall = 5.0f; +constexpr float MarginExtraSmall = 2.5f; +constexpr float MarginBetweenSections = 30.0f; +constexpr float MarginBetweenViews = 30.0f; namespace { From d875091ffb1dc494c5a7681ab79c6b7ec1bfc61a Mon Sep 17 00:00:00 2001 From: ILA-dd Date: Thu, 10 Sep 2026 22:54:09 +0500 Subject: [PATCH 2/2] Add Linux music player support --- CMakeLists.txt | 51 ++++++++--- cmake/FindDBus.cmake | 27 ++++++ cmake/FindPulseAudio.cmake | 36 ++++++++ docs/BUILDING.md | 3 + src/engine/client/updater.cpp | 4 + src/engine/shared/storage.cpp | 12 +-- src/game/client/components/chat.cpp | 2 +- src/game/client/components/menus_settings.cpp | 6 +- src/game/client/components/scoreboard.cpp | 1 - .../components/tclient/amf_hud_editor.cpp | 40 ++++----- .../components/tclient/menus_tclient.cpp | 12 +-- .../tclient/music_player/music_player.cpp | 85 ++++++++++++++----- .../music_player/music_player_lyrics.cpp | 14 +-- .../music_player/music_player_lyrics.h | 4 +- .../music_player/visualizer/source_pulse.cpp | 2 +- src/game/client/gameclient.cpp | 2 - 16 files changed, 221 insertions(+), 80 deletions(-) create mode 100644 cmake/FindDBus.cmake create mode 100644 cmake/FindPulseAudio.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ce2273..4a13cd7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -417,7 +417,9 @@ if(NOT MSVC AND NOT HAIKU) add_cxx_compiler_flag_if_supported(OUR_FLAGS_OWN -Wdynamic-class-memaccess) # clang add_cxx_compiler_flag_if_supported(OUR_FLAGS_OWN -Wlogical-op) add_cxx_compiler_flag_if_supported(OUR_FLAGS_OWN -Wno-missing-field-initializers) - add_cxx_compiler_flag_if_supported(OUR_FLAGS_OWN -Wno-nullability-completeness) # Mac OS build on github + if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_cxx_compiler_flag_if_supported(OUR_FLAGS_OWN -Wno-nullability-completeness) # Mac OS build on GitHub + endif() add_cxx_compiler_flag_if_supported(OUR_FLAGS_OWN -Wno-psabi) # parameter passing for argument of type ‘__gnu_cxx::__normal_iterator*, std::vector, std::allocator > > >’ changed in GCC 7.1 add_cxx_compiler_flag_if_supported(OUR_FLAGS_OWN -Wno-unused-parameter) add_cxx_compiler_flag_if_supported(OUR_FLAGS_OWN -Wrestrict) @@ -614,6 +616,14 @@ find_package(Python3) find_package(Rust) find_package(SDL2) find_package(SQLite3) +if(CLIENT AND TARGET_OS STREQUAL "linux") + # The Music Player reads MPRIS sessions over D-Bus and uses PulseAudio or + # PipeWire's PulseAudio compatibility server for its realtime visualizer. + # Both integrations remain optional so minimal Linux installations keep + # building with the passive player fallback. + find_package(DBus) + find_package(PulseAudio) +endif() if(DISCORD) find_package(DiscordSdk) endif() @@ -2479,6 +2489,7 @@ set_src(GAME_SHARED GLOB src/game team_state.h teamscore.cpp teamscore.h + tune_zone_colors.h tuning.h version.h voting.h @@ -2781,12 +2792,19 @@ if(CLIENT) components/tclient/fast_practice.h components/tclient/inputs.cpp components/tclient/inputs.h + components/tclient/menus_tclient.cpp + components/tclient/mod.cpp + components/tclient/mod.h + components/tclient/moving_tiles.cpp + components/tclient/moving_tiles.h + components/tclient/mumble.cpp + components/tclient/mumble.h + components/tclient/music_player/media_decoder.cpp + components/tclient/music_player/media_decoder.h components/tclient/music_player/music_player.cpp components/tclient/music_player/music_player.h components/tclient/music_player/music_player_lyrics.cpp components/tclient/music_player/music_player_lyrics.h - components/tclient/music_player/media_decoder.cpp - components/tclient/music_player/media_decoder.h components/tclient/music_player/visualizer/analyzer.cpp components/tclient/music_player/visualizer/analyzer.h components/tclient/music_player/visualizer/service.cpp @@ -2799,13 +2817,6 @@ if(CLIENT) components/tclient/music_player/visualizer/source_pulse.cpp components/tclient/music_player/visualizer/source_wasapi.cpp components/tclient/music_player/visualizer/types.h - components/tclient/menus_tclient.cpp - components/tclient/mod.cpp - components/tclient/mod.h - components/tclient/moving_tiles.cpp - components/tclient/moving_tiles.h - components/tclient/mumble.cpp - components/tclient/mumble.h components/tclient/outlines.cpp components/tclient/outlines.h components/tclient/pet.cpp @@ -3030,6 +3041,15 @@ if(CLIENT) ${LIBS} ) + if(TARGET_OS STREQUAL "linux") + if(DBus_FOUND) + list(APPEND LIBS_CLIENT ${DBUS_LIBRARIES}) + endif() + if(PulseAudio_FOUND) + list(APPEND LIBS_CLIENT ${PULSEAUDIO_LIBRARIES}) + endif() + endif() + if(DISCORD) if(NOT DISCORD_DYNAMIC) list(APPEND LIBS_CLIENT discord-shared) @@ -3166,6 +3186,7 @@ if(CLIENT) target_include_directories(game-client SYSTEM PRIVATE # tidy-alphabetical-start + ${DBUS_INCLUDE_DIRS} ${DISCORDSDK_INCLUDE_DIRS} ${FFMPEG_INCLUDE_DIRS} ${FREETYPE_INCLUDE_DIRS} @@ -3174,6 +3195,7 @@ if(CLIENT) ${OPUSFILE_INCLUDE_DIRS} ${OPUS_INCLUDE_DIRS} ${PNG_INCLUDE_DIRS} + ${PULSEAUDIO_INCLUDE_DIRS} ${SDL2_INCLUDE_DIRS} ${WAVPACK_INCLUDE_DIRS} # tidy-alphabetical-end @@ -3216,6 +3238,15 @@ if(CLIENT) target_compile_definitions(game-client PRIVATE CONF_BACKEND_VULKAN) endif() + if(TARGET_OS STREQUAL "linux") + if(DBus_FOUND) + target_compile_definitions(game-client PRIVATE BC_MUSICPLAYER_HAS_DBUS=1) + endif() + if(PulseAudio_FOUND) + target_compile_definitions(game-client PRIVATE BC_MUSICPLAYER_HAS_PULSE=1) + endif() + endif() + if(WIN32) # Keep the updater a separately built, versioned executable. It is the # only process that replaces DDNet.exe after the main client exits. diff --git a/cmake/FindDBus.cmake b/cmake/FindDBus.cmake new file mode 100644 index 0000000..faa45a2 --- /dev/null +++ b/cmake/FindDBus.cmake @@ -0,0 +1,27 @@ +if(NOT CMAKE_CROSSCOMPILING) + find_package(PkgConfig QUIET) + pkg_check_modules(PC_DBUS QUIET dbus-1) +endif() + +find_library(DBUS_LIBRARY + NAMES dbus-1 + HINTS ${PC_DBUS_LIBDIR} ${PC_DBUS_LIBRARY_DIRS} + ${CROSSCOMPILING_NO_CMAKE_SYSTEM_PATH} +) +find_path(DBUS_INCLUDEDIR + NAMES dbus/dbus.h + HINTS ${PC_DBUS_INCLUDEDIR} ${PC_DBUS_INCLUDE_DIRS} + ${CROSSCOMPILING_NO_CMAKE_SYSTEM_PATH} +) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(DBus DEFAULT_MSG DBUS_LIBRARY DBUS_INCLUDEDIR) + +mark_as_advanced(DBUS_LIBRARY DBUS_INCLUDEDIR) + +if(DBUS_FOUND) + set(DBUS_LIBRARIES ${DBUS_LIBRARY}) + # dbus/dbus-arch-deps.h is architecture-specific and is not necessarily in + # the same include directory as dbus/dbus.h. + set(DBUS_INCLUDE_DIRS ${DBUS_INCLUDEDIR} ${PC_DBUS_INCLUDE_DIRS}) +endif() diff --git a/cmake/FindPulseAudio.cmake b/cmake/FindPulseAudio.cmake new file mode 100644 index 0000000..c810b6c --- /dev/null +++ b/cmake/FindPulseAudio.cmake @@ -0,0 +1,36 @@ +if(NOT CMAKE_CROSSCOMPILING) + find_package(PkgConfig QUIET) + pkg_check_modules(PC_PULSEAUDIO QUIET libpulse) + pkg_check_modules(PC_PULSEAUDIO_SIMPLE QUIET libpulse-simple) +endif() + +find_library(PULSEAUDIO_LIBRARY + NAMES pulse + HINTS ${PC_PULSEAUDIO_LIBDIR} ${PC_PULSEAUDIO_LIBRARY_DIRS} + ${CROSSCOMPILING_NO_CMAKE_SYSTEM_PATH} +) +find_library(PULSEAUDIO_SIMPLE_LIBRARY + NAMES pulse-simple + HINTS ${PC_PULSEAUDIO_SIMPLE_LIBDIR} ${PC_PULSEAUDIO_SIMPLE_LIBRARY_DIRS} + ${CROSSCOMPILING_NO_CMAKE_SYSTEM_PATH} +) +find_path(PULSEAUDIO_INCLUDEDIR + NAMES pulse/pulseaudio.h + HINTS ${PC_PULSEAUDIO_INCLUDEDIR} ${PC_PULSEAUDIO_INCLUDE_DIRS} + ${CROSSCOMPILING_NO_CMAKE_SYSTEM_PATH} +) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(PulseAudio DEFAULT_MSG + PULSEAUDIO_LIBRARY + PULSEAUDIO_SIMPLE_LIBRARY + PULSEAUDIO_INCLUDEDIR +) + +mark_as_advanced(PULSEAUDIO_LIBRARY PULSEAUDIO_SIMPLE_LIBRARY PULSEAUDIO_INCLUDEDIR) + +if(PulseAudio_FOUND) + # libpulse-simple depends on libpulse, so keep the dependent library first. + set(PULSEAUDIO_LIBRARIES ${PULSEAUDIO_SIMPLE_LIBRARY} ${PULSEAUDIO_LIBRARY}) + set(PULSEAUDIO_INCLUDE_DIRS ${PULSEAUDIO_INCLUDEDIR} ${PC_PULSEAUDIO_INCLUDE_DIRS}) +endif() diff --git a/docs/BUILDING.md b/docs/BUILDING.md index e890516..891c4fa 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -53,6 +53,9 @@ FLUSH PRIVILEGES; You need to install the [Vulkan SDK](https://vulkan.lunarg.com/sdk/home) and set the `VULKAN_SDK` environment flag accordingly. Default value is ON for Linux, and OFF for Windows and macOS. +* **Linux Music Player support**
+ The music player reads MPRIS-compatible players (such as Spotify, VLC, mpv, and browser media sessions) from the desktop session bus. Its realtime visualizer uses PulseAudio; this also works with PipeWire when its PulseAudio compatibility service is enabled. Install `libdbus-1-dev libpulse-dev` on Debian/Ubuntu, `dbus libpulse` on Arch Linux, or `dbus-devel pulseaudio-libs-devel` on Fedora. These are optional: when unavailable, the client still builds, but the music player uses its passive fallback. + * **-GNinja**
Use the Ninja build system instead of Make. This automatically parallelizes the build and is generally faster. Compile with `ninja` instead of `make`. Install Ninja with `sudo apt install ninja-build` on Debian, `sudo pacman -S --needed ninja` on Arch Linux. diff --git a/src/engine/client/updater.cpp b/src/engine/client/updater.cpp index 4b4f9f8..6480b04 100644 --- a/src/engine/client/updater.cpp +++ b/src/engine/client/updater.cpp @@ -34,6 +34,7 @@ void BuildReleasesUrl(char *pBuf, int BufSize) str_format(pBuf, BufSize, "%s%ct=%lld", pSource, str_find(pSource, "?") ? '&' : '?', (long long)time_timestamp()); } +#if defined(CONF_FAMILY_WINDOWS) bool StrEndsWithNoCase(const char *pStr, const char *pSuffix) { if(!pStr || !pSuffix) @@ -42,6 +43,7 @@ bool StrEndsWithNoCase(const char *pStr, const char *pSuffix) const int SuffixLen = str_length(pSuffix); return SuffixLen <= StrLen && str_comp_nocase(pStr + StrLen - SuffixLen, pSuffix) == 0; } +#endif std::string ToLowerAscii(const char *pStr) { @@ -216,6 +218,7 @@ bool ParseLatestRelease(json_value *pJson, char *pVersion, int VersionSize, char return true; } +#if defined(CONF_FAMILY_WINDOWS) void StripFilename(char *pPath) { for(int i = str_length(pPath) - 1; i >= 0; --i) @@ -250,6 +253,7 @@ bool IsLegacyPortableDirectory(const char *pDirectory) str_comp_nocase_num(pName, pPrefix, str_length(pPrefix)) == 0 && StrEndsWithNoCase(pName, pSuffix); } +#endif } // namespace CUpdater::CUpdater() diff --git a/src/engine/shared/storage.cpp b/src/engine/shared/storage.cpp index eba0473..452c4cd 100644 --- a/src/engine/shared/storage.cpp +++ b/src/engine/shared/storage.cpp @@ -320,12 +320,14 @@ class CStorage : public IStorage // Development builds place the executable in a configuration // subdirectory (for example, build/release) and the data directory // next to it (build/data). - fs_parent_dir(aDir); - str_format(aBuf, sizeof(aBuf), "%s/data/mapres", aDir); - if(fs_is_dir(aBuf)) + if(fs_parent_dir(aDir) == 0) { - str_format(m_aDatadir, sizeof(m_aDatadir), "%s/data", aDir); - return; + str_format(aBuf, sizeof(aBuf), "%s/data/mapres", aDir); + if(fs_is_dir(aBuf)) + { + str_format(m_aDatadir, sizeof(m_aDatadir), "%s/data", aDir); + return; + } } } } diff --git a/src/game/client/components/chat.cpp b/src/game/client/components/chat.cpp index 944d703..05f0fc0 100644 --- a/src/game/client/components/chat.cpp +++ b/src/game/client/components/chat.cpp @@ -332,7 +332,7 @@ void CChat::ExecuteSmoothHudContextAction(int Action) if(Action == 0) // Copy { - char aText[MAX_NAME_LENGTH + MAX_LINE_LENGTH + 4]; + char aText[static_cast(MAX_NAME_LENGTH) + MAX_LINE_LENGTH + 4]; str_format(aText, sizeof(aText), "%s%s%s", Line.m_aName, Line.m_ClientId >= 0 ? ": " : "", Line.m_aText); Input()->SetClipboardText(aText); str_copy(m_aSmoothHudNotification, TCLocalize("Message copied", "AMF Client"), sizeof(m_aSmoothHudNotification)); diff --git a/src/game/client/components/menus_settings.cpp b/src/game/client/components/menus_settings.cpp index 6f82992..b26c2d4 100644 --- a/src/game/client/components/menus_settings.cpp +++ b/src/game/client/components/menus_settings.cpp @@ -4749,9 +4749,9 @@ struct SAssetOrganizerPopupContext : public SPopupMenuId const ColorRGBA PopupBorder = ColorRGBA(0.10f, 0.13f, 0.17f, 0.78f); auto DoOrganizerPopupButton = [pUi, &PopupAccentPressed, &PopupAccentSoft, &PopupNeutral, &PopupNeutralHover, &PopupNeutralDisabled, &PopupBorder](CButtonContainer *pButton, const char *pText, const CUIRect *pRect, float FontSize, bool Enabled = true) { const bool Hovered = Enabled && pUi->MouseHovered(pRect); - const bool Active = Enabled && pUi->CheckActiveItem(pButton); - const ColorRGBA Fill = !Enabled ? PopupNeutralDisabled : (Active ? PopupAccentPressed : (Hovered ? PopupNeutralHover : PopupNeutral)); - const ColorRGBA Border = !Enabled ? PopupBorder : (Active ? PopupAccentPressed : (Hovered ? PopupAccentSoft : PopupBorder)); + const bool IsActive = Enabled && pUi->CheckActiveItem(pButton); + const ColorRGBA Fill = !Enabled ? PopupNeutralDisabled : (IsActive ? PopupAccentPressed : (Hovered ? PopupNeutralHover : PopupNeutral)); + const ColorRGBA Border = !Enabled ? PopupBorder : (IsActive ? PopupAccentPressed : (Hovered ? PopupAccentSoft : PopupBorder)); pRect->Draw(Border, IGraphics::CORNER_ALL, 3.0f); CUIRect Inner = *pRect; Inner.Margin(1.0f, &Inner); diff --git a/src/game/client/components/scoreboard.cpp b/src/game/client/components/scoreboard.cpp index eb21a22..8812a75 100644 --- a/src/game/client/components/scoreboard.cpp +++ b/src/game/client/components/scoreboard.cpp @@ -982,7 +982,6 @@ void CScoreboard::RenderScoreboard(CUIRect Scoreboard, int Team, int CountStart, CountryOffset, Row.y + (Spacing + TeeSizeMod * 5.0f) / 2.0f, CountryLength, Row.h - Spacing - TeeSizeMod * 5.0f); // ping - ColorRGBA PingColor = TextRender()->DefaultTextColor(); if(g_Config.m_ClEnablePingColor) { TextRender()->TextColor(WithRenderAlpha(color_cast(ColorHSLA((300.0f - std::clamp(pInfo->m_Latency, 0, 300)) / 1000.0f, 1.0f, 0.5f)))); diff --git a/src/game/client/components/tclient/amf_hud_editor.cpp b/src/game/client/components/tclient/amf_hud_editor.cpp index b34ba98..7123c52 100644 --- a/src/game/client/components/tclient/amf_hud_editor.cpp +++ b/src/game/client/components/tclient/amf_hud_editor.cpp @@ -222,27 +222,27 @@ namespace // the HUD element is very wide, tall or tiny. DrawRoundedRectOutline(pGraphics, Rect, IGraphics::CORNER_NONE, 0.0f, Color); - const float ShortSide = std::min(Rect.w, Rect.h); - const float CornerSize = std::min(std::clamp(ShortSide * 0.14f, 0.8f, 5.0f), ShortSide * 0.25f); - const float Outset = std::min(CornerSize * 0.22f, 0.8f); - if(CornerSize <= 0.0f) - return; + const float ShortSide = std::min(Rect.w, Rect.h); + const float CornerSize = std::min(std::clamp(ShortSide * 0.14f, 0.8f, 5.0f), ShortSide * 0.25f); + const float Outset = std::min(CornerSize * 0.22f, 0.8f); + if(CornerSize <= 0.0f) + return; const float Right = Rect.x + Rect.w; const float Bottom = Rect.y + Rect.h; pGraphics->TextureClear(); - pGraphics->QuadsBegin(); - pGraphics->SetColor(Color); - // Four identical, mirrored triangular markers. They are decoration only; - // no input state or resize hitbox is attached to any corner. - IGraphics::CFreeformItem aCorners[] = { - {Rect.x - Outset, Rect.y - Outset, Rect.x - Outset + CornerSize, Rect.y - Outset, Rect.x - Outset, Rect.y - Outset + CornerSize, Rect.x - Outset, Rect.y - Outset + CornerSize}, - {Right + Outset, Rect.y - Outset, Right + Outset - CornerSize, Rect.y - Outset, Right + Outset, Rect.y - Outset + CornerSize, Right + Outset, Rect.y - Outset + CornerSize}, - {Rect.x - Outset, Bottom + Outset, Rect.x - Outset + CornerSize, Bottom + Outset, Rect.x - Outset, Bottom + Outset - CornerSize, Rect.x - Outset, Bottom + Outset - CornerSize}, - {Right + Outset, Bottom + Outset, Right + Outset - CornerSize, Bottom + Outset, Right + Outset, Bottom + Outset - CornerSize, Right + Outset, Bottom + Outset - CornerSize}, - }; - pGraphics->QuadsDrawFreeform(aCorners, sizeof(aCorners) / sizeof(aCorners[0])); - pGraphics->QuadsEnd(); + pGraphics->QuadsBegin(); + pGraphics->SetColor(Color); + // Four identical, mirrored triangular markers. They are decoration only; + // no input state or resize hitbox is attached to any corner. + IGraphics::CFreeformItem aCorners[] = { + {Rect.x - Outset, Rect.y - Outset, Rect.x - Outset + CornerSize, Rect.y - Outset, Rect.x - Outset, Rect.y - Outset + CornerSize, Rect.x - Outset, Rect.y - Outset + CornerSize}, + {Right + Outset, Rect.y - Outset, Right + Outset - CornerSize, Rect.y - Outset, Right + Outset, Rect.y - Outset + CornerSize, Right + Outset, Rect.y - Outset + CornerSize}, + {Rect.x - Outset, Bottom + Outset, Rect.x - Outset + CornerSize, Bottom + Outset, Rect.x - Outset, Bottom + Outset - CornerSize, Rect.x - Outset, Bottom + Outset - CornerSize}, + {Right + Outset, Bottom + Outset, Right + Outset - CornerSize, Bottom + Outset, Right + Outset, Bottom + Outset - CornerSize, Right + Outset, Bottom + Outset - CornerSize}, + }; + pGraphics->QuadsDrawFreeform(aCorners, sizeof(aCorners) / sizeof(aCorners[0])); + pGraphics->QuadsEnd(); } CUIRect ClampToBounds(CUIRect Rect, float Width, float Height) @@ -252,12 +252,6 @@ namespace return Rect; } - float ChatInputBottomExtra(const CChat &Chat) - { - const float ScaledFontSize = Chat.FontSize() * (8.0f / 6.0f); - return std::max(2.25f * ScaledFontSize, std::max(ScaledFontSize + 4.0f, 16.0f)); - } - } // namespace void CAmfHudEditor::OnConsoleInit() diff --git a/src/game/client/components/tclient/menus_tclient.cpp b/src/game/client/components/tclient/menus_tclient.cpp index eeb7150..e0acb38 100644 --- a/src/game/client/components/tclient/menus_tclient.cpp +++ b/src/game/client/components/tclient/menus_tclient.cpp @@ -2317,12 +2317,12 @@ void CMenus::RenderSettingsAmfClient(CUIRect MainView) // Both module-local buttons use the same activation path; their owning // card decides where the row is placed. - auto RenderModuleHudEditorButton = [&](CUIRect &HudEditorButton, CButtonContainer *pButtonContainer) { + auto RenderModuleHudEditorButton = [&](CUIRect &HudEditorButtonRect, CButtonContainer *pButtonContainer) { if(DoButtonLineSize_Menu( pButtonContainer, TCLocalize("HUD Editor", "AMF Client"), 0, - &HudEditorButton, + &HudEditorButtonRect, LineSize, false, nullptr, @@ -2335,7 +2335,7 @@ void CMenus::RenderSettingsAmfClient(CUIRect MainView) } GameClient()->m_Tooltips.DoToolTip( pButtonContainer, - &HudEditorButton, + &HudEditorButtonRect, CanOpenHudEditor ? TCLocalize("Drag, scale, reset and save the position of AMF HUD elements.", "AMF Client") : TCLocalize("Connect to a server first to open the HUD Editor.", "AMF Client")); }; @@ -2770,9 +2770,9 @@ void CMenus::RenderSettingsAmfClient(CUIRect MainView) auto DoMusicPlayerDropDown = [&](const char *pLabel, int &Value, const char **ppNames, int NumNames, CUi::SDropDownState &State, CScrollRegion &ScrollRegion) { MusicPlayerContent.HSplitTop(MarginExtraSmall, nullptr, &MusicPlayerContent); MusicPlayerContent.HSplitTop(LineSize, &Button, &MusicPlayerContent); - CUIRect Label, Select; - Button.VSplitLeft(std::min(150.0f, Button.w * 0.45f), &Label, &Select); - Ui()->DoLabel(&Label, pLabel, 12.0f, TEXTALIGN_ML); + CUIRect DropDownLabel, Select; + Button.VSplitLeft(std::min(150.0f, Button.w * 0.45f), &DropDownLabel, &Select); + Ui()->DoLabel(&DropDownLabel, pLabel, 12.0f, TEXTALIGN_ML); State.m_SelectionPopupContext.m_pScrollRegion = &ScrollRegion; Value = Ui()->DoDropDown(&Select, std::clamp(Value, 0, NumNames - 1), ppNames, NumNames, State); }; diff --git a/src/game/client/components/tclient/music_player/music_player.cpp b/src/game/client/components/tclient/music_player/music_player.cpp index d1722fa..20336db 100644 --- a/src/game/client/components/tclient/music_player/music_player.cpp +++ b/src/game/client/components/tclient/music_player/music_player.cpp @@ -87,6 +87,10 @@ namespace static constexpr float VISUALIZER_ATTACK_RATE = 46.0f; static constexpr float VISUALIZER_RELEASE_RATE = 19.0f; static constexpr int COVER_BAR_TINT_CELLS = MUSIC_PLAYER_MAX_VISUALIZER_BARS * COVER_BAR_SEGMENTS; + // Some MPRIS players publish Playing before their decoder has advanced the + // Position property. Require a meaningful position change before starting the + // local HUD clock, so loading media cannot advance the timer or lyrics. + static constexpr int64_t MPRIS_PLAYBACK_START_CONFIRMATION_MS = 50; static CUIRect HudToUiRect(const CUIRect &HudRect, const CUIRect &UiScreen, float HudWidth, float HudHeight) { @@ -190,6 +194,7 @@ namespace { bool m_Valid = false; std::string m_ServiceId; + std::string m_TrackId; std::string m_Title; std::string m_Artist; std::string m_Album; @@ -352,7 +357,7 @@ namespace static std::string BuildSnapshotTrackKey(const SNowPlayingSnapshot &Snapshot) { - return Snapshot.m_ServiceId + "|" + Snapshot.m_Title + "|" + Snapshot.m_Artist + "|" + std::to_string(Snapshot.m_DurationMs); + return Snapshot.m_ServiceId + "|" + Snapshot.m_TrackId + "|" + Snapshot.m_Title + "|" + Snapshot.m_Artist + "|" + std::to_string(Snapshot.m_DurationMs); } #if BC_MUSICPLAYER_HAS_WINRT @@ -570,6 +575,10 @@ namespace if(VariantToInt64(ValueVariantIter, DurationUs)) Out.m_DurationMs = std::max(0, DurationUs / 1000); } + else if(str_comp(pKey, "mpris:trackid") == 0) + { + VariantToString(ValueVariantIter, Out.m_TrackId); + } else if(str_comp(pKey, "mpris:artUrl") == 0) { std::string ArtUrl; @@ -2498,9 +2507,10 @@ class CMusicPlayer::CImpl std::string m_PlaybackTrackKey; int64_t m_PlaybackAnchorPositionMs = 0; int64_t m_PlaybackAnchorTick = 0; - int64_t m_LastRawSnapshotPositionMs = -1; + int64_t m_LastProviderPositionMs = 0; int64_t m_LastTimelineUpdatedTicks = 0; EMusicPlaybackState m_PlaybackAnchorState = EMusicPlaybackState::STOPPED; + bool m_PlaybackPositionConfirmed = false; std::string m_LastArtKey; std::shared_ptr m_pArtRequest; std::shared_ptr m_pArtDecodeJob; @@ -2609,8 +2619,9 @@ class CMusicPlayer::CImpl m_PlaybackTrackKey.clear(); m_PlaybackAnchorPositionMs = 0; m_PlaybackAnchorTick = 0; - m_LastRawSnapshotPositionMs = -1; + m_LastProviderPositionMs = 0; m_PlaybackAnchorState = EMusicPlaybackState::STOPPED; + m_PlaybackPositionConfirmed = false; m_VisualTrackKey.clear(); m_VisualPositionMs = 0.0f; } @@ -3030,13 +3041,18 @@ class CMusicPlayer::CImpl int64_t DisplayPositionMs() const { int64_t Position = std::max(0, m_PlaybackAnchorPositionMs); - if(m_PlaybackAnchorState == EMusicPlaybackState::PLAYING && m_PlaybackAnchorTick > 0) + if(m_PlaybackAnchorState == EMusicPlaybackState::PLAYING && m_PlaybackPositionConfirmed && m_PlaybackAnchorTick > 0) Position += ((time_get() - m_PlaybackAnchorTick) * 1000) / time_freq(); if(m_Snapshot.m_DurationMs > 0) Position = std::min(Position, m_Snapshot.m_DurationMs); return Position; } + bool PlaybackClockRunning() const + { + return m_PlaybackAnchorState == EMusicPlaybackState::PLAYING && m_PlaybackPositionConfirmed; + } + void AttachVisualizerData(SNowPlayingSnapshot &Snapshot) const { Snapshot.m_HasVisualizer = false; @@ -3095,29 +3111,54 @@ class CMusicPlayer::CImpl const std::string TrackKey = BuildSnapshotTrackKey(Snapshot); const int64_t SnapshotPosition = std::clamp(Snapshot.m_PositionMs, 0, std::max(Snapshot.m_DurationMs, Snapshot.m_PositionMs)); - const bool NewTrack = TrackKey != m_PlaybackTrackKey; // A duration-only media-session update is not a new track. Treating it as // one can rewind the lyrics to a stale provider position. - const bool TrackIdentityChanged = NewTrack && - (Snapshot.m_ServiceId != m_Snapshot.m_ServiceId || - Snapshot.m_Title != m_Snapshot.m_Title || - Snapshot.m_Artist != m_Snapshot.m_Artist); + const bool TrackIdentityChanged = + Snapshot.m_ServiceId != m_Snapshot.m_ServiceId || + Snapshot.m_Title != m_Snapshot.m_Title || + Snapshot.m_Artist != m_Snapshot.m_Artist || + (!Snapshot.m_TrackId.empty() && Snapshot.m_TrackId != m_Snapshot.m_TrackId); const bool StateChanged = Snapshot.m_PlaybackState != m_PlaybackAnchorState; const int64_t PredictedPosition = DisplayPositionMs(); const int64_t Drift = SnapshotPosition - PredictedPosition; - const bool SnapshotSeekedBackwards = - m_LastRawSnapshotPositionMs >= 0 && - SnapshotPosition + 500 < m_LastRawSnapshotPositionMs; - // Browser/MPRIS providers can freeze their reported position while the - // local clock advances. Only a real raw backward movement is a seek. - const bool StaleRewind = Drift < -1500 && !SnapshotSeekedBackwards; + // A Linux MPRIS Position property is a snapshot rather than a timeline + // event. Some players intermittently report a stale zero/old position for + // the current track. Never rewind a playing MPRIS timeline from that alone: + // a track id/title change or pause/resume remains an explicit reset. Windows + // provides LastUpdatedTime, which makes a fresh backward timeline sample a + // reliable seek and keeps that platform's direct-seek behavior intact. const bool FreshTimelineSample = Snapshot.m_TimelineUpdatedTicks == 0 || m_LastTimelineUpdatedTicks == 0 || Snapshot.m_TimelineUpdatedTicks != m_LastTimelineUpdatedTicks; + const bool HasAuthoritativeTimelineTimestamp = Snapshot.m_TimelineUpdatedTicks != 0; + const bool NewOrResumedPlaying = + Snapshot.m_PlaybackState == EMusicPlaybackState::PLAYING && + (TrackIdentityChanged || m_PlaybackAnchorTick == 0 || StateChanged); + if(NewOrResumedPlaying) + { + // Windows pairs the timeline position with LastUpdatedTime. MPRIS does + // not, so wait for its next advancing Position sample before trusting + // the provider's Playing status. + m_PlaybackPositionConfirmed = HasAuthoritativeTimelineTimestamp; + } + else if(Snapshot.m_PlaybackState != EMusicPlaybackState::PLAYING) + { + m_PlaybackPositionConfirmed = false; + } + + const bool PlaybackJustConfirmed = + Snapshot.m_PlaybackState == EMusicPlaybackState::PLAYING && + !m_PlaybackPositionConfirmed && + SnapshotPosition >= m_LastProviderPositionMs + MPRIS_PLAYBACK_START_CONFIRMATION_MS; + if(PlaybackJustConfirmed) + m_PlaybackPositionConfirmed = true; const bool NeedsHardResync = TrackIdentityChanged || m_PlaybackAnchorTick == 0 || - (!StaleRewind && (StateChanged || (FreshTimelineSample && std::llabs(Drift) > 1500))); + StateChanged || + PlaybackJustConfirmed || + (FreshTimelineSample && + (Drift > 1500 || (HasAuthoritativeTimelineTimestamp && Drift < -1500))); if(NeedsHardResync) { @@ -3146,7 +3187,7 @@ class CMusicPlayer::CImpl m_PlaybackTrackKey = TrackKey; m_PlaybackAnchorState = Snapshot.m_PlaybackState; - m_LastRawSnapshotPositionMs = SnapshotPosition; + m_LastProviderPositionMs = SnapshotPosition; m_LastTimelineUpdatedTicks = Snapshot.m_TimelineUpdatedTicks; m_Snapshot = std::move(Snapshot); m_LastSnapshotTick = Now; @@ -3631,7 +3672,7 @@ void CMusicPlayer::OnUpdate() m_pImpl->m_Snapshot.m_Album.c_str(), m_pImpl->m_Snapshot.m_DurationMs, m_pImpl->DisplayPositionMs(), - m_pImpl->m_Snapshot.m_PlaybackState == EMusicPlaybackState::PLAYING); + m_pImpl->PlaybackClockRunning()); } else { @@ -3997,7 +4038,7 @@ void CMusicPlayer::RenderMusicPlayer(bool ForcePreview) const float UiTitleFont = TitleFont * UiFontScale; if(LyricsEnabled) { - CUIRect UiLyricsTimerText; + CUIRect UiLyricsTimerText{}; float TimerFont = 0.0f; std::string TimerText; if(DrawLyricsTimerTab) @@ -4010,7 +4051,11 @@ void CMusicPlayer::RenderMusicPlayer(bool ForcePreview) UiLyricsTimerText = {UiLyricsTimerTab.x + (UiLyricsTimerTab.w - TimerTextWidth) * 0.5f, UiLyricsTimerTab.y + (UiLyricsTimerTab.h - TimerFont) * 0.5f, TimerTextWidth, TimerFont}; } const float CountdownCenterX = UiLyricsTimerText.w > 0.0f ? UiLyricsTimerText.x + UiLyricsTimerText.w * 0.5f : UiTitleRect.x + UiTitleRect.w * 0.5f; - m_pImpl->m_Lyrics.Render(TextRender(), Ui(), UiTitleRect, UiTitleFont, Delta, CountdownCenterX); + // Use the exact same monotonic timestamp as the visible timer. The lyrics + // component keeps its own clock for fetching/state transitions, but giving + // it the HUD position here prevents a poll or frame boundary from selecting + // a line for a neighbouring timestamp. + m_pImpl->m_Lyrics.Render(TextRender(), Ui(), UiTitleRect, UiTitleFont, Delta, CountdownCenterX, DisplayPositionMs); if(DrawLyricsTimerTab) TextRender()->Text(UiLyricsTimerText.x, UiLyricsTimerText.y, TimerFont, TimerText.c_str(), -1.0f); } diff --git a/src/game/client/components/tclient/music_player/music_player_lyrics.cpp b/src/game/client/components/tclient/music_player/music_player_lyrics.cpp index edaefa6..3c28c86 100644 --- a/src/game/client/components/tclient/music_player/music_player_lyrics.cpp +++ b/src/game/client/components/tclient/music_player/music_player_lyrics.cpp @@ -107,7 +107,7 @@ void CMusicPlayerLyrics::TickDisplay(float Delta) m_NotFoundDisplayMs += std::max(0.0f, Delta) * 1000.0f; } -int CMusicPlayerLyrics::ResolveDisplayLineIndex() const +int CMusicPlayerLyrics::ResolveDisplayLineIndex(int64_t PositionMs) const { if(m_DisplayState == EDisplayState::NotFound) return (m_NotFoundDisplayMs < (float)NOT_FOUND_HOLD_MS) ? FALLBACK_NOT_FOUND : FALLBACK_TITLE; @@ -115,7 +115,7 @@ int CMusicPlayerLyrics::ResolveDisplayLineIndex() const if(m_DisplayState != EDisplayState::Ready) return LINE_NONE; - const int64_t PositionMs = CurrentPositionMs(); + PositionMs = std::max(0, PositionMs); int LineIndex = FindLineIndex(PositionMs); if(LineIndex < 0 && !m_vLines.empty()) { @@ -141,7 +141,7 @@ float CMusicPlayerLyrics::PreferredTextSlotWidth(ITextRender *pTextRender, float // The no-media fallback and track title shrink to content; lyrics, errors, and countdown keep full width. const bool ShowNoMedia = m_DisplayState == EDisplayState::Idle; - const bool ShowTitle = m_DisplayState == EDisplayState::NotFound && ResolveDisplayLineIndex() == FALLBACK_TITLE; + const bool ShowTitle = m_DisplayState == EDisplayState::NotFound && ResolveDisplayLineIndex(CurrentPositionMs()) == FALLBACK_TITLE; if(!ShowNoMedia && !ShowTitle) return ClampedMax; @@ -827,7 +827,7 @@ float CMusicPlayerLyrics::ComputeTextStartX(float AreaLeft, float AreaWidth, flo return std::clamp(IdealStartX, MinStartX, MaxStartX); } -void CMusicPlayerLyrics::Render(ITextRender *pTextRender, CUi *pUi, const CUIRect &Area, float FontSize, float Delta, float CountdownCenterX) +void CMusicPlayerLyrics::Render(ITextRender *pTextRender, CUi *pUi, const CUIRect &Area, float FontSize, float Delta, float CountdownCenterX, int64_t PositionMs) { if(pTextRender == nullptr || pUi == nullptr || Area.w <= 0.0f || Area.h <= 0.0f) return; @@ -862,8 +862,10 @@ void CMusicPlayerLyrics::Render(ITextRender *pTextRender, CUi *pUi, const CUIRec return; } - const int64_t PositionMs = CurrentPositionMs(); - int LineIndex = ResolveDisplayLineIndex(); + PositionMs = std::max(0, PositionMs); + if(m_ClockDurationMs > 0) + PositionMs = std::min(PositionMs, m_ClockDurationMs); + int LineIndex = ResolveDisplayLineIndex(PositionMs); int64_t CountdownRemainingMs = 0; if(IsCountdownIndex(LineIndex) && !m_vLines.empty()) CountdownRemainingMs = m_vLines.front().m_StartMs - PositionMs; diff --git a/src/game/client/components/tclient/music_player/music_player_lyrics.h b/src/game/client/components/tclient/music_player/music_player_lyrics.h index 8255dc0..3712d2a 100644 --- a/src/game/client/components/tclient/music_player/music_player_lyrics.h +++ b/src/game/client/components/tclient/music_player/music_player_lyrics.h @@ -55,7 +55,7 @@ class CMusicPlayerLyrics // HUD text-slot width for the current lyrics/status/title content, clamped to MaxWidth. float PreferredTextSlotWidth(ITextRender *pTextRender, float FontSize, float MaxWidth, float Scale, float WidthScale) const; - void Render(ITextRender *pTextRender, CUi *pUi, const CUIRect &Area, float FontSize, float Delta, float CountdownCenterX); + void Render(ITextRender *pTextRender, CUi *pUi, const CUIRect &Area, float FontSize, float Delta, float CountdownCenterX, int64_t PositionMs); private: // Display index: -99 = none, -20/-19 = not-found then title, -3/-2/-1 = countdown 3/2/1, >=0 = lyric line. @@ -84,7 +84,7 @@ class CMusicPlayerLyrics static bool IsFallbackIndex(int Index) { return Index == FALLBACK_NOT_FOUND || Index == FALLBACK_TITLE; } static int CountdownDigit(int Index) { return -Index; } const char *FallbackText(int Index) const; - int ResolveDisplayLineIndex() const; + int ResolveDisplayLineIndex(int64_t PositionMs) const; void ApplyCacheEntry(const SCacheEntry &Entry); void StartRequest(IHttp *pHttp, const char *pTitle, const char *pArtist, const char *pAlbum, int64_t DurationMs); void ProcessRequest(); diff --git a/src/game/client/components/tclient/music_player/visualizer/source_pulse.cpp b/src/game/client/components/tclient/music_player/visualizer/source_pulse.cpp index e9a630c..0f70bfa 100644 --- a/src/game/client/components/tclient/music_player/visualizer/source_pulse.cpp +++ b/src/game/client/components/tclient/music_player/visualizer/source_pulse.cpp @@ -36,7 +36,7 @@ namespace #if defined(CONF_PLATFORM_LINUX) && defined(BC_MUSICPLAYER_HAS_PULSE) && BC_MUSICPLAYER_HAS_PULSE static bool VisualizerDebugEnabled(int Level) { - return g_Config.m_DbgMusicPlayer >= Level; + return g_Config.m_AmfMusicPlayerDebug >= Level; } static void VisualizerDebugLog(int Level, const char *pFmt, ...) diff --git a/src/game/client/gameclient.cpp b/src/game/client/gameclient.cpp index b995f58..ab6b321 100644 --- a/src/game/client/gameclient.cpp +++ b/src/game/client/gameclient.cpp @@ -1764,9 +1764,7 @@ void CGameClient::ReleaseAmfDiscordProcessHandles(bool PreserveOwnedRestoreHelpe void CGameClient::UpdateAmfConfigPersistence() { - constexpr int64_t SAVE_DEBOUNCE = 250; const int64_t Now = time_get(); - const int64_t DebounceTicks = time_freq() * SAVE_DEBOUNCE / 1000; if(m_AmfConfigSavePending && Now >= m_AmfConfigSaveDeadline) { if(!ConfigManager()->SaveDomain(ConfigDomain::AMFCLIENT))