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 8c7a7d2..5c7b680 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..4d3baca 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,11 @@ 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. Ignore the initial snapshot, then require either one + // meaningful position change or a real audio signal 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 +195,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 +358,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 +576,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 +2508,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 +2620,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 +3042,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 +3112,58 @@ 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 or a real audio + // signal before trusting the provider's Playing status. + m_PlaybackPositionConfirmed = HasAuthoritativeTimelineTimestamp; + } + else if(Snapshot.m_PlaybackState != EMusicPlaybackState::PLAYING) + { + m_PlaybackPositionConfirmed = false; + } + + const bool AwaitingMprisPlaybackConfirmation = + Snapshot.m_PlaybackState == EMusicPlaybackState::PLAYING && + !m_PlaybackPositionConfirmed && + !NewOrResumedPlaying; + const bool PlaybackJustConfirmed = + AwaitingMprisPlaybackConfirmation && + (SnapshotPosition >= m_LastProviderPositionMs + MPRIS_PLAYBACK_START_CONFIRMATION_MS || + (Snapshot.m_HasVisualizer && Snapshot.m_Visualizer.m_HasRealtimeSignal)); + 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 +3192,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 +3677,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 +4043,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 +4056,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..8540697 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 @@ -22,7 +22,9 @@ namespace { static constexpr float LYRICS_SLOT_WIDTH = 70.0f; - static constexpr float LYRICS_LINE_SLIDE_MS = 260.0f; + static constexpr float LYRICS_LINE_SLIDE_MIN_MS = 80.0f; + static constexpr float LYRICS_LINE_SLIDE_MAX_MS = 260.0f; + static constexpr float LYRICS_LINE_SLIDE_INTERVAL_FRACTION = 0.12f; static constexpr float LYRICS_TITLE_MARQUEE_GAP_FACTOR = 2.5f; static constexpr ColorRGBA LYRICS_PASSED_COLOR(1.0f, 1.0f, 1.0f, 1.0f); static constexpr ColorRGBA LYRICS_UPCOMING_COLOR(0.45f, 0.45f, 0.48f, 1.0f); @@ -107,7 +109,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 +117,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 +143,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; @@ -162,6 +164,7 @@ void CMusicPlayerLyrics::Reset() m_DisplayState = EDisplayState::Idle; m_vLines.clear(); m_OfflineRetryAt = 0; + m_UseLyricsOvhFallback = false; ClearActiveTrack(); } @@ -170,6 +173,7 @@ void CMusicPlayerLyrics::ClearActiveTrack() m_CurrentLineIndex = LINE_NONE; m_OutgoingLineIndex = LINE_NONE; m_LineTransitionT = 1.0f; + m_LineTransitionDurationMs = LYRICS_LINE_SLIDE_MAX_MS; m_LayoutValid = false; m_LayoutText.clear(); m_vCharMetrics.clear(); @@ -187,6 +191,7 @@ void CMusicPlayerLyrics::ClearLayoutState() m_CurrentLineIndex = LINE_NONE; m_OutgoingLineIndex = LINE_NONE; m_LineTransitionT = 1.0f; + m_LineTransitionDurationMs = LYRICS_LINE_SLIDE_MAX_MS; m_LayoutValid = false; m_LayoutText.clear(); m_vCharMetrics.clear(); @@ -202,6 +207,7 @@ void CMusicPlayerLyrics::Disable() m_DisplayState = EDisplayState::Idle; m_vLines.clear(); m_OfflineRetryAt = 0; + m_UseLyricsOvhFallback = false; ClearActiveTrack(); } @@ -215,19 +221,19 @@ void CMusicPlayerLyrics::AbortRequest() m_RequestKey.clear(); } -std::string CMusicPlayerLyrics::BuildCacheKey(const char *pTitle, const char *pArtist, const char *pAlbum, int64_t DurationMs) +std::string CMusicPlayerLyrics::BuildCacheKey(const char *pTitle, const char *pArtist, const char *pAlbum) { - const int DurationSec = (int)((std::max(0, DurationMs) + 500) / 1000); std::string Key; Key.reserve(160); - Key += "v3|"; + // Duration often arrives in a later MPRIS metadata update. It narrows the + // LRCLIB request, but it is not track identity: changing it must not discard + // lyrics that are already on screen and trigger another network request. + Key += "v4|"; Key += pArtist ? pArtist : ""; Key += '|'; Key += pTitle ? pTitle : ""; Key += '|'; Key += pAlbum ? pAlbum : ""; - Key += '|'; - Key += std::to_string(DurationSec); return Key; } @@ -363,6 +369,45 @@ bool CMusicPlayerLyrics::ParseSyncedLyrics(const char *pSyncedLyrics, std::vecto return !vOut.empty(); } +bool CMusicPlayerLyrics::ParsePlainLyrics(const char *pLyrics, int64_t DurationMs, std::vector &vOut) +{ + vOut.clear(); + if(pLyrics == nullptr || pLyrics[0] == '\0') + return false; + + const char *p = pLyrics; + while(*p) + { + while(*p == '\r' || *p == '\n') + ++p; + if(*p == '\0') + break; + + const char *pLineStart = p; + while(*p && *p != '\n' && *p != '\r') + ++p; + const char *pLineEnd = p; + while(pLineStart < pLineEnd && std::isspace((unsigned char)*pLineStart)) + ++pLineStart; + while(pLineEnd > pLineStart && std::isspace((unsigned char)pLineEnd[-1])) + --pLineEnd; + if(pLineStart == pLineEnd) + continue; + + SLine Line; + Line.m_Text.assign(pLineStart, pLineEnd); + vOut.push_back(std::move(Line)); + } + + if(vOut.empty()) + return false; + + const int64_t TotalDurationMs = DurationMs > 0 ? DurationMs : (int64_t)vOut.size() * 4000; + for(int i = 0; i < (int)vOut.size(); ++i) + vOut[i].m_StartMs = (int64_t)i * TotalDurationMs / (int)vOut.size(); + return true; +} + void CMusicPlayerLyrics::MergeConsecutiveIdenticalLines(std::vector &vLines) { if(vLines.size() < 2) @@ -415,7 +460,13 @@ void CMusicPlayerLyrics::StartRequest(IHttp *pHttp, const char *pTitle, const ch const int DurationSec = (int)((std::max(0, DurationMs) + 500) / 1000); char aUrl[2048]; - if(DurationSec >= 1 && DurationSec <= 3600) + if(m_UseLyricsOvhFallback) + { + str_format(aUrl, sizeof(aUrl), + "https://api.lyrics.ovh/v1/%s/%s", + aEscapedArtist, aEscapedTitle); + } + else if(DurationSec >= 1 && DurationSec <= 3600) { str_format(aUrl, sizeof(aUrl), "https://lrclib.net/api/get?track_name=%s&artist_name=%s&album_name=%s&duration=%d", @@ -429,10 +480,11 @@ void CMusicPlayerLyrics::StartRequest(IHttp *pHttp, const char *pTitle, const ch } m_pRequest = HttpGet(aUrl); - m_pRequest->Timeout(CTimeout{10000, 0, 500, 10}); + m_pRequest->Timeout(CTimeout{m_UseLyricsOvhFallback ? 5000 : 2500, 0, 500, 5}); m_pRequest->LogProgress(HTTPLOG::FAILURE); m_pRequest->FailOnErrorStatus(false); - m_pRequest->HeaderString("Lrclib-Client", "AMF Client/" AMF_CLIENT_VERSION " (https://github.com/AlyaDDNet/AMF-Client)"); + m_pRequest->IpResolve(IPRESOLVE::V4); + m_pRequest->HeaderString("User-Agent", "AMF Client/" AMF_CLIENT_VERSION " (https://github.com/AlyaDDNet/AMF-Client)"); m_RequestKey = m_ActiveKey; m_DisplayState = EDisplayState::Loading; pHttp->Run(m_pRequest); @@ -451,28 +503,45 @@ void CMusicPlayerLyrics::ProcessRequest() if(FinishedKey != m_ActiveKey) return; - // Done() is also true for ERROR/ABORTED — must not call StatusCode() unless DONE. - if(pFinished->State() != EHttpState::DONE) - { + auto RetryWithLyricsOvh = [this]() { + if(m_UseLyricsOvhFallback) + return false; + // lrclib.net is unavailable from some networks. Retry the same track with + // the public fallback instead of replacing its lyrics with an error. + m_UseLyricsOvhFallback = true; + m_DisplayState = EDisplayState::Offline; + m_OfflineRetryAt = 0; + return true; + }; + auto ShowOffline = [this]() { m_DisplayState = EDisplayState::Offline; m_vLines.clear(); ClearActiveTrack(); m_OfflineRetryAt = time_get() + time_freq() * LYRICS_OFFLINE_RETRY_MS / 1000; + }; + + // Done() is also true for ERROR/ABORTED — must not call StatusCode() unless DONE. + if(pFinished->State() != EHttpState::DONE) + { + if(RetryWithLyricsOvh()) + return; + ShowOffline(); return; } const int StatusCode = pFinished->StatusCode(); if(StatusCode == 0) { - m_DisplayState = EDisplayState::Offline; - m_vLines.clear(); - ClearActiveTrack(); - m_OfflineRetryAt = time_get() + time_freq() * LYRICS_OFFLINE_RETRY_MS / 1000; + if(RetryWithLyricsOvh()) + return; + ShowOffline(); return; } if(StatusCode == 404) { + if(RetryWithLyricsOvh()) + return; SCacheEntry Entry; Entry.m_State = EDisplayState::NotFound; if(m_Cache.size() >= LYRICS_CACHE_MAX) @@ -484,24 +553,40 @@ void CMusicPlayerLyrics::ProcessRequest() if(StatusCode < 200 || StatusCode >= 300) { - m_DisplayState = EDisplayState::Offline; - m_vLines.clear(); - ClearActiveTrack(); - m_OfflineRetryAt = time_get() + time_freq() * LYRICS_OFFLINE_RETRY_MS / 1000; + if(RetryWithLyricsOvh()) + return; + ShowOffline(); return; } json_value *pRoot = pFinished->ResultJson(); SCacheEntry Entry; Entry.m_State = EDisplayState::NotFound; + bool FoundLyrics = false; if(pRoot != nullptr && pRoot != &json_value_none && pRoot->type == json_object) { - const char *pSynced = JsonStringOrEmpty(json_object_get(pRoot, "syncedLyrics")); - if(ParseSyncedLyrics(pSynced, Entry.m_vLines)) + if(!m_UseLyricsOvhFallback) + { + const char *pSynced = JsonStringOrEmpty(json_object_get(pRoot, "syncedLyrics")); + FoundLyrics = ParseSyncedLyrics(pSynced, Entry.m_vLines); + if(!FoundLyrics) + { + const char *pPlain = JsonStringOrEmpty(json_object_get(pRoot, "plainLyrics")); + FoundLyrics = ParsePlainLyrics(pPlain, m_ClockDurationMs, Entry.m_vLines); + } + } + else + { + const char *pPlain = JsonStringOrEmpty(json_object_get(pRoot, "lyrics")); + FoundLyrics = ParsePlainLyrics(pPlain, m_ClockDurationMs, Entry.m_vLines); + } + if(FoundLyrics) Entry.m_State = EDisplayState::Ready; } if(pRoot) json_value_free(pRoot); + if(!FoundLyrics && RetryWithLyricsOvh()) + return; if(m_Cache.size() >= LYRICS_CACHE_MAX) m_Cache.clear(); @@ -569,7 +654,7 @@ void CMusicPlayerLyrics::Update(IHttp *pHttp, const char *pTitle, const char *pA return; } - const std::string Key = BuildCacheKey(pTitle, pArtist, pAlbum, DurationMs); + const std::string Key = BuildCacheKey(pTitle, pArtist, pAlbum); const bool NewTrack = Key != m_ActiveKey; if(NewTrack) { @@ -578,6 +663,7 @@ void CMusicPlayerLyrics::Update(IHttp *pHttp, const char *pTitle, const char *pA m_vLines.clear(); ClearActiveTrack(); m_OfflineRetryAt = 0; + m_UseLyricsOvhFallback = false; SyncMediaClock(SnapshotPositionMs, DurationMs, Playing, true); const auto It = m_Cache.find(Key); @@ -827,7 +913,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 +948,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; @@ -874,6 +962,18 @@ void CMusicPlayerLyrics::Render(ITextRender *pTextRender, CUi *pUi, const CUIRec m_CurrentLineIndex != LINE_NONE && LineIndex == m_CurrentLineIndex + 1; if(SequentialForward) { + m_LineTransitionDurationMs = LYRICS_LINE_SLIDE_MAX_MS; + if(m_CurrentLineIndex >= 0 && LineIndex >= 0) + { + const int64_t PreviousLineDurationMs = std::max(1, m_vLines[LineIndex].m_StartMs - m_vLines[m_CurrentLineIndex].m_StartMs); + // The karaoke wipe already consumes the whole timestamp interval. Keep + // the line slide short, but make it faster for tightly packed lyrics so + // it finishes before the next text change. + m_LineTransitionDurationMs = std::clamp( + (float)PreviousLineDurationMs * LYRICS_LINE_SLIDE_INTERVAL_FRACTION, + LYRICS_LINE_SLIDE_MIN_MS, + LYRICS_LINE_SLIDE_MAX_MS); + } m_OutgoingLineIndex = m_CurrentLineIndex; m_LineTransitionT = 0.0f; } @@ -881,6 +981,7 @@ void CMusicPlayerLyrics::Render(ITextRender *pTextRender, CUi *pUi, const CUIRec { m_OutgoingLineIndex = LINE_NONE; m_LineTransitionT = 1.0f; + m_LineTransitionDurationMs = LYRICS_LINE_SLIDE_MAX_MS; } m_CurrentLineIndex = LineIndex; m_LayoutValid = false; @@ -888,7 +989,7 @@ void CMusicPlayerLyrics::Render(ITextRender *pTextRender, CUi *pUi, const CUIRec if(m_LineTransitionT < 1.0f) { - m_LineTransitionT = std::clamp(m_LineTransitionT + Delta * 1000.0f / LYRICS_LINE_SLIDE_MS, 0.0f, 1.0f); + m_LineTransitionT = std::clamp(m_LineTransitionT + Delta * 1000.0f / m_LineTransitionDurationMs, 0.0f, 1.0f); if(m_LineTransitionT >= 1.0f) m_OutgoingLineIndex = LINE_NONE; } 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..1146f67 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. @@ -77,14 +77,15 @@ class CMusicPlayerLyrics float m_PrefixWidth = 0.0f; // width of text[0 .. byteOffset) }; - static std::string BuildCacheKey(const char *pTitle, const char *pArtist, const char *pAlbum, int64_t DurationMs); + static std::string BuildCacheKey(const char *pTitle, const char *pArtist, const char *pAlbum); static bool ParseLrcTimestamp(const char *pText, int64_t &OutMs, const char **ppEnd); + static bool ParsePlainLyrics(const char *pLyrics, int64_t DurationMs, std::vector &vOut); static void MergeConsecutiveIdenticalLines(std::vector &vLines); static bool IsCountdownIndex(int Index) { return Index >= -3 && Index <= -1; } 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(); @@ -105,6 +106,7 @@ class CMusicPlayerLyrics std::shared_ptr m_pRequest; std::unordered_map m_Cache; int64_t m_OfflineRetryAt = 0; + bool m_UseLyricsOvhFallback = false; float m_NotFoundDisplayMs = 0.0f; float m_TitleMarqueeOffset = 0.0f; @@ -117,6 +119,7 @@ class CMusicPlayerLyrics int m_CurrentLineIndex = LINE_NONE; int m_OutgoingLineIndex = LINE_NONE; float m_LineTransitionT = 1.0f; + float m_LineTransitionDurationMs = 260.0f; std::string m_LayoutText; float m_LayoutFontSize = 0.0f; 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))