8 Commits 3a73fe4f74 ... 7db7263ef5

Author SHA1 Message Date
  kiwec 7db7263ef5 Add sort_skins_by_name convar 2 months ago
  kiwec 6226b0ad74 Add missing chat commands and keyboard shortcuts 2 months ago
  kiwec 07b9cbf0b7 Fix map links not selecting map button 2 months ago
  kiwec 5fd81c91b3 Add fast path for Sound::setPositionMS 2 months ago
  kiwec 42544aa514 Lower non-ASIO/WASAPI BASS audio latency 2 months ago
  kiwec 695cd5867d Fix std::clamp whining 2 months ago
  kiwec 4dbf25bd0c Support user links and beatmapset links 2 months ago
  kiwec 2a2cf819bd Add main_menu_use_server_logo convar 2 months ago

+ 0 - 0
libraries/bassfx/2.4.12.1


+ 23 - 0
src/App/Osu/BanchoUsers.cpp

@@ -13,6 +13,29 @@ UserInfo* find_user(UString username) {
     return NULL;
 }
 
+UserInfo* find_user_starting_with(UString prefix, UString last_match) {
+    bool matched = last_match.length() == 0;
+    for(auto pair : online_users) {
+        auto user = pair.second;
+        if(!matched) {
+            if(user->name == last_match) {
+                matched = true;
+            }
+            continue;
+        }
+
+        if(user->name.startsWithIgnoreCase(prefix)) {
+            return user;
+        }
+    }
+
+    if(last_match.length() == 0) {
+        return NULL;
+    } else {
+        return find_user_starting_with(prefix, "");
+    }
+}
+
 UserInfo* get_user_info(u32 user_id, bool fetch) {
     auto it = online_users.find(user_id);
     if(it != online_users.end()) {

+ 1 - 0
src/App/Osu/BanchoUsers.h

@@ -36,4 +36,5 @@ extern std::unordered_map<u32, UserInfo*> online_users;
 extern std::vector<u32> friends;
 
 UserInfo* find_user(UString username);
+UserInfo* find_user_starting_with(UString prefix, UString last_match);
 UserInfo* get_user_info(u32 user_id, bool fetch = false);

+ 3 - 3
src/App/Osu/Beatmap.cpp

@@ -1790,13 +1790,13 @@ void Beatmap::handlePreviewPlay() {
             should_start_song_at_preview_point = false;
 
             if(start_at_song_beginning) {
-                m_music->setPositionMS(0);
+                m_music->setPositionMS_fast(0);
                 m_bWasSeekFrame = true;
             } else if(m_iContinueMusicPos != 0) {
-                m_music->setPositionMS(m_iContinueMusicPos);
+                m_music->setPositionMS_fast(m_iContinueMusicPos);
                 m_bWasSeekFrame = true;
             } else {
-                m_music->setPositionMS(m_selectedDifficulty2->getPreviewTime() < 0
+                m_music->setPositionMS_fast(m_selectedDifficulty2->getPreviewTime() < 0
                                            ? (unsigned long)(m_music->getLengthMS() * 0.40f)
                                            : m_selectedDifficulty2->getPreviewTime());
                 m_bWasSeekFrame = true;

+ 24 - 12
src/App/Osu/Changelog.cpp

@@ -29,20 +29,32 @@ Changelog::Changelog() : ScreenBackable() {
     CHANGELOG latest;
     latest.title =
         UString::format("%.2f (%s, %s)", convar->getConVarByName("osu_version")->getFloat(), __DATE__, __TIME__);
-    latest.changes.push_back("- Added cursor trail customization settings");
-    latest.changes.push_back("- Added instafade checkbox");
-    latest.changes.push_back("- Added more UI sounds");
-    latest.changes.push_back("- Added submit_after_pause convar");
-    latest.changes.push_back("- Chat: added support for /me command");
-    latest.changes.push_back("- Chat: added support for links");
-    latest.changes.push_back("- Chat: added support for map links (auto-downloads)");
-    latest.changes.push_back("- Chat: added support for multiplayer invite links");
-    latest.changes.push_back("- FPS counter will now display worst frametime instead of current frametime");
-    latest.changes.push_back("- Improved song browser performance");
-    latest.changes.push_back("- Skins are now sorted alphabetically, ignoring meme characters");
-    latest.changes.push_back("- Unlocked osu_drain_kill convar");
+    latest.changes.push_back("- Added sort_skins_by_name convar");
+    latest.changes.push_back("- Added setting to prevent servers from replacing the main menu logo");
+    latest.changes.push_back("- Chat: added missing chat commands");
+    latest.changes.push_back("- Chat: added missing keyboard shortcuts");
+    latest.changes.push_back("- Chat: added support for user links");
+    latest.changes.push_back("- Chat: improved map link support");
+    latest.changes.push_back("- Fixed freeze when switching between songs in song browser");
+    latest.changes.push_back("- Lowered audio latency for default (not ASIO/WASAPI) output");
     changelogs.push_back(latest);
 
+    CHANGELOG v35_06;
+    v35_06.title = "35.06 (2024-06-17)";
+    v35_06.changes.push_back("- Added cursor trail customization settings");
+    v35_06.changes.push_back("- Added instafade checkbox");
+    v35_06.changes.push_back("- Added more UI sounds");
+    v35_06.changes.push_back("- Added submit_after_pause convar");
+    v35_06.changes.push_back("- Chat: added support for /me command");
+    v35_06.changes.push_back("- Chat: added support for links");
+    v35_06.changes.push_back("- Chat: added support for map links (auto-downloads)");
+    v35_06.changes.push_back("- Chat: added support for multiplayer invite links");
+    v35_06.changes.push_back("- FPS counter will now display worst frametime instead of current frametime");
+    v35_06.changes.push_back("- Improved song browser performance");
+    v35_06.changes.push_back("- Skins are now sorted alphabetically, ignoring meme characters");
+    v35_06.changes.push_back("- Unlocked osu_drain_kill convar");
+    changelogs.push_back(v35_06);
+
     CHANGELOG v35_05;
     v35_05.title = "35.05 (2024-06-13)";
     v35_05.changes.push_back("- Fixed Artist/Creator/Title sorting to be in A-Z order");

+ 304 - 29
src/App/Osu/Chat.cpp

@@ -5,10 +5,12 @@
 #include "AnimationHandler.h"
 #include "Bancho.h"
 #include "BanchoNetworking.h"
+#include "BanchoUsers.h"
 #include "Beatmap.h"
 #include "CBaseUIButton.h"
 #include "CBaseUIContainer.h"
 #include "CBaseUILabel.h"
+#include "CBaseUITextbox.h"
 #include "ChatLink.h"
 #include "Engine.h"
 #include "Font.h"
@@ -23,6 +25,7 @@
 #include "ResourceManager.h"
 #include "RoomScreen.h"
 #include "Skin.h"
+#include "SongBrowser/ScoreButton.h"
 #include "SongBrowser/SongBrowser.h"
 #include "SoundEngine.h"
 #include "SpectatorScreen.h"
@@ -340,9 +343,215 @@ void Chat::mouse_update(bool *propagate_clicks) {
     m_input_box->focus(false);
 }
 
+void Chat::handle_command(UString msg) {
+    if(msg == UString("/clear")) {
+        m_selected_channel->messages.clear();
+        updateLayout(osu->getScreenSize());
+        return;
+    }
+
+    if(msg == UString("/close") || msg == UString("/p") || msg == UString("/part")) {
+        leave(m_selected_channel->name);
+        return;
+    }
+
+    if(msg == UString("/help") || msg == UString("/keys")) {
+        env->openURLInDefaultBrowser("https://osu.ppy.sh/wiki/en/Client/Interface/Chat_console");
+        return;
+    }
+
+    if(msg == UString("/np")) {
+        auto diff = osu->getSelectedBeatmap()->getSelectedDifficulty2();
+        if(diff == NULL) {
+            addSystemMessage("You are not listening to anything.");
+            return;
+        }
+
+        UString song_name = UString::format("%s - %s [%s]", diff->getArtist().c_str(), diff->getTitle().c_str(), diff->getDifficultyName().c_str());
+        UString song_link = UString::format("[https://osu.%s/beatmaps/%d %s]", bancho.endpoint.toUtf8(), diff->getID(), song_name.toUtf8());
+
+        UString np_msg;
+        if(osu->isInPlayMode()) {
+            np_msg = UString::format("\001ACTION is playing %s", song_link.toUtf8());
+
+            auto mod_string = ScoreButton::getModsStringForDisplay(osu->getScore()->getModsLegacy());
+            if(mod_string.length() > 0) {
+                np_msg.append("(+");
+                np_msg.append(mod_string);
+                np_msg.append(")");
+            }
+
+            np_msg.append("\001");
+        } else {
+            np_msg = UString::format("\001ACTION is listening to %s\001", song_link.toUtf8());
+        }
+
+        send_message(np_msg);
+        return;
+    }
+
+    if(msg.startsWith("/addfriend ")) {
+        auto friend_name = msg.substr(11);
+        auto user = find_user(friend_name);
+        if(!user) {
+            addSystemMessage(UString::format("User '%s' not found. Are they online?", friend_name.toUtf8()));
+            return;
+        }
+
+        if(user->is_friend()) {
+            addSystemMessage(UString::format("You are already friends with %s!", friend_name.toUtf8()));
+        } else {
+            Packet packet;
+            packet.id = FRIEND_ADD;
+            write<u32>(&packet, user->user_id);
+            send_packet(packet);
+
+            friends.push_back(user->user_id);
+
+            addSystemMessage(UString::format("You are now friends with %s.", friend_name.toUtf8()));
+        }
+
+        return;
+    }
+
+    if(msg.startsWith("/bb ")) {
+        addChannel("BanchoBot", true);
+        send_message(msg.substr(4));
+        return;
+    }
+
+    if(msg == UString("/away")) {
+        away_msg = "";
+        addSystemMessage("Away message removed.");
+        return;
+    }
+    if(msg.startsWith("/away ")) {
+        away_msg = msg.substr(6);
+        addSystemMessage(UString::format("Away message set to '%s'.", away_msg.toUtf8()));
+        return;
+    }
+
+    if(msg.startsWith("/delfriend ")) {
+        auto friend_name = msg.substr(11);
+        auto user = find_user(friend_name);
+        if(!user) {
+            addSystemMessage(UString::format("User '%s' not found. Are they online?", friend_name.toUtf8()));
+            return;
+        }
+
+        if(user->is_friend()) {
+            Packet packet;
+            packet.id = FRIEND_REMOVE;
+            write<u32>(&packet, user->user_id);
+            send_packet(packet);
+
+            auto it = std::find(friends.begin(), friends.end(), user->user_id);
+            if(it != friends.end()) {
+                friends.erase(it);
+            }
+
+            addSystemMessage(UString::format("You are no longer friends with %s.", friend_name.toUtf8()));
+        } else {
+            addSystemMessage(UString::format("You aren't friends with %s!", friend_name.toUtf8()));
+        }
+
+        return;
+    }
+
+    if(msg.startsWith("/me ")) {
+        auto new_text = msg.substr(3);
+        new_text.insert(0, "\001ACTION");
+        new_text.append("\001");
+        send_message(new_text);
+        return;
+    }
+
+    if(msg.startsWith("/chat ") || msg.startsWith("/msg ") || msg.startsWith("/query ")) {
+        auto username = msg.substr(msg.find(L" "));
+        addChannel(username, true);
+        return;
+    }
+
+    if(msg.startsWith("/invite ")) {
+        if(!bancho.is_in_a_multi_room()) {
+            addSystemMessage("You are not in a multiplayer room!");
+            return;
+        }
+
+        auto username = msg.substr(8);
+        auto invite_msg = UString::format("\001ACTION has invited you to join [osump://%d/%s %s]\001", bancho.room.id, bancho.room.password.toUtf8(), bancho.room.name.toUtf8());
+
+        Packet packet;
+        packet.id = SEND_PRIVATE_MESSAGE;
+        write_string(&packet, (char *)bancho.username.toUtf8());
+        write_string(&packet, (char *)invite_msg.toUtf8());
+        write_string(&packet, (char *)username.toUtf8());
+        write<u32>(&packet, bancho.user_id);
+        send_packet(packet);
+
+        addSystemMessage(UString::format("%s has been invited to the game.", username.toUtf8()));
+        return;
+    }
+
+    if(msg.startsWith("/j ") || msg.startsWith("/join ")) {
+        auto channel = msg.substr(msg.find(L" "));
+        join(channel);
+        return;
+    }
+
+    if(msg.startsWith("/p ") || msg.startsWith("/part ")) {
+        auto channel = msg.substr(msg.find(L" "));
+        leave(channel);
+        return;
+    }
+
+    addSystemMessage("This command is not supported.");
+}
+
 void Chat::onKeyDown(KeyboardEvent &key) {
     if(!m_bVisible) return;
 
+    if(engine->getKeyboard()->isAltDown()) {
+        i32 tab_select = -1;
+        if(key.getKeyCode() == KEY_1) tab_select = 0;
+        if(key.getKeyCode() == KEY_2) tab_select = 1;
+        if(key.getKeyCode() == KEY_3) tab_select = 2;
+        if(key.getKeyCode() == KEY_4) tab_select = 3;
+        if(key.getKeyCode() == KEY_5) tab_select = 4;
+        if(key.getKeyCode() == KEY_6) tab_select = 5;
+        if(key.getKeyCode() == KEY_7) tab_select = 6;
+        if(key.getKeyCode() == KEY_8) tab_select = 7;
+        if(key.getKeyCode() == KEY_9) tab_select = 8;
+        if(key.getKeyCode() == KEY_0) tab_select = 9;
+
+        if(tab_select != -1) {
+            if(tab_select >= m_channels.size()) {
+                key.consume();
+                return;
+            }
+
+            key.consume();
+            switchToChannel(m_channels[tab_select]);
+            return;
+        }
+    }
+
+    if(key.getKeyCode() == KEY_PAGEUP) {
+        if(m_selected_channel != NULL) {
+            key.consume();
+            m_selected_channel->ui->scrollY(getSize().y - input_box_height);
+            return;
+        }
+    }
+
+    if(key.getKeyCode() == KEY_PAGEDOWN) {
+        if(m_selected_channel != NULL) {
+            key.consume();
+            m_selected_channel->ui->scrollY(-(getSize().y - input_box_height));
+            return;
+        }
+    }
+
     // Escape: close chat
     if(key.getKeyCode() == KEY_ESCAPE) {
         if(isVisibilityForced()) return;
@@ -357,38 +566,17 @@ void Chat::onKeyDown(KeyboardEvent &key) {
     if(key.getKeyCode() == KEY_ENTER) {
         key.consume();
         if(m_selected_channel != NULL && m_input_box->getText().length() > 0) {
-            if(m_input_box->getText() == UString("/close")) {
-                leave(m_selected_channel->name);
-                return;
-            }
-
-            if(m_input_box->getText().startsWith("/me ")) {
-                auto new_text = m_input_box->getText().substr(3);
-                new_text.insert(0, "\001ACTION");
-                new_text.append("\001");
-                m_input_box->setText(new_text);
+            if(m_input_box->getText()[0] == L'/') {
+                handle_command(m_input_box->getText());
+            } else {
+                send_message(m_input_box->getText());
             }
 
-            Packet packet;
-            packet.id = m_selected_channel->name[0] == '#' ? SEND_PUBLIC_MESSAGE : SEND_PRIVATE_MESSAGE;
-            write_string(&packet, (char *)bancho.username.toUtf8());
-            write_string(&packet, (char *)m_input_box->getText().toUtf8());
-            write_string(&packet, (char *)m_selected_channel->name.toUtf8());
-            write<u32>(&packet, bancho.user_id);
-            send_packet(packet);
-
-            // Server doesn't echo the message back
-            addMessage(m_selected_channel->name, ChatMessage{
-                                                     .tms = time(NULL),
-                                                     .author_id = bancho.user_id,
-                                                     .author_name = bancho.username,
-                                                     .text = m_input_box->getText(),
-                                                 });
-
             engine->getSound()->play(osu->getSkin()->m_messageSent);
-
             m_input_box->clear();
         }
+        tab_completion_prefix = "";
+        tab_completion_match = "";
         return;
     }
 
@@ -429,8 +617,48 @@ void Chat::onKeyDown(KeyboardEvent &key) {
         return;
     }
 
+    // TAB: Complete nickname
+    // KEY_TAB doesn't work on Linux
+    if(key.getKeyCode() == 65056 || key.getKeyCode() == KEY_TAB) {
+        key.consume();
+
+        auto text = m_input_box->getText();
+        i32 username_start_idx = text.findLast(" ", 0, m_input_box->m_iCaretPosition) + 1;
+        i32 username_end_idx = m_input_box->m_iCaretPosition;
+        i32 username_len = username_end_idx - username_start_idx;
+
+        if(tab_completion_prefix.length() == 0) {
+            tab_completion_prefix = text.substr(username_start_idx, username_len);
+        } else {
+            username_start_idx = m_input_box->m_iCaretPosition - tab_completion_match.length();
+            username_len = username_end_idx - username_start_idx;
+        }
+
+        auto user = find_user_starting_with(tab_completion_prefix, tab_completion_match);
+        if(user) {
+            tab_completion_match = user->name;
+
+            // Remove current username, add new username
+            m_input_box->m_sText.erase(m_input_box->m_iCaretPosition - username_len, username_len);
+            m_input_box->m_iCaretPosition -= username_len;
+            m_input_box->m_sText.insert(m_input_box->m_iCaretPosition, tab_completion_match);
+            m_input_box->m_iCaretPosition += tab_completion_match.length();
+            m_input_box->setText(m_input_box->m_sText);
+            m_input_box->updateTextPos();
+            m_input_box->tickCaret();
+
+            Sound *sounds[] = {osu->getSkin()->m_typing1, osu->getSkin()->m_typing2, osu->getSkin()->m_typing3,
+                               osu->getSkin()->m_typing4};
+            engine->getSound()->play(sounds[rand() % 4]);
+        }
+
+        return;
+    }
+
     // Typing in chat: capture keypresses
     if(!engine->getKeyboard()->isAltDown()) {
+        tab_completion_prefix = "";
+        tab_completion_match = "";
         m_input_box->onKeyDown(key);
         key.consume();
         return;
@@ -520,7 +748,8 @@ void Chat::addChannel(UString channel_name, bool switch_to) {
 }
 
 void Chat::addMessage(UString channel_name, ChatMessage msg, bool mark_unread) {
-    if(msg.author_id > 0 && channel_name[0] != '#' && msg.author_name != bancho.username) {
+    bool is_pm = msg.author_id > 0 && channel_name[0] != '#' && msg.author_name != bancho.username;
+    if(is_pm) {
         // If it's a PM, the channel title should be the one who sent the message
         channel_name = msg.author_name;
     }
@@ -543,10 +772,38 @@ void Chat::addMessage(UString channel_name, ChatMessage msg, bool mark_unread) {
         if(chan->messages.size() > 100) {
             chan->messages.erase(chan->messages.begin());
         }
-        return;
+
+        break;
+    }
+
+    if(is_pm && away_msg.length() > 0) {
+        Packet packet;
+        packet.id = SEND_PRIVATE_MESSAGE;
+        write_string(&packet, (char *)bancho.username.toUtf8());
+        write_string(&packet, (char *)away_msg.toUtf8());
+        write_string(&packet, (char *)msg.author_name.toUtf8());
+        write<u32>(&packet, bancho.user_id);
+        send_packet(packet);
+
+        // Server doesn't echo the message back
+        addMessage(channel_name, ChatMessage{
+                                     .tms = time(NULL),
+                                     .author_id = bancho.user_id,
+                                     .author_name = bancho.username,
+                                     .text = away_msg,
+                                 });
     }
 }
 
+void Chat::addSystemMessage(UString msg) {
+    addMessage(m_selected_channel->name, ChatMessage{
+                                             .tms = time(NULL),
+                                             .author_id = 0,
+                                             .author_name = "",
+                                             .text = msg,
+                                         });
+}
+
 void Chat::removeChannel(UString channel_name) {
     ChatChannel *chan = NULL;
     for(auto c : m_channels) {
@@ -689,6 +946,24 @@ void Chat::leave(UString channel_name) {
     engine->getSound()->play(osu->getSkin()->m_closeChatTab);
 }
 
+void Chat::send_message(UString msg) {
+    Packet packet;
+    packet.id = m_selected_channel->name[0] == '#' ? SEND_PUBLIC_MESSAGE : SEND_PRIVATE_MESSAGE;
+    write_string(&packet, (char *)bancho.username.toUtf8());
+    write_string(&packet, (char *)msg.toUtf8());
+    write_string(&packet, (char *)m_selected_channel->name.toUtf8());
+    write<u32>(&packet, bancho.user_id);
+    send_packet(packet);
+
+    // Server doesn't echo the message back
+    addMessage(m_selected_channel->name, ChatMessage{
+                                             .tms = time(NULL),
+                                             .author_id = bancho.user_id,
+                                             .author_name = bancho.username,
+                                             .text = msg,
+                                         });
+}
+
 void Chat::onDisconnect() {
     for(auto chan : m_channels) {
         delete chan;

+ 7 - 0
src/App/Osu/Chat.h

@@ -49,12 +49,15 @@ class Chat : public OsuScreen {
     void switchToChannel(ChatChannel *chan);
     void addChannel(UString channel_name, bool switch_to = false);
     void addMessage(UString channel_name, ChatMessage msg, bool mark_unread = true);
+    void addSystemMessage(UString msg);
     void removeChannel(UString channel_name);
     void updateLayout(Vector2 newResolution);
     void updateButtonLayout(Vector2 screen);
 
     void join(UString channel_name);
     void leave(UString channel_name);
+    void handle_command(UString msg);
+    void send_message(UString msg);
     void onDisconnect();
 
     virtual CBaseUIContainer *setVisible(bool visible);
@@ -79,4 +82,8 @@ class Chat : public OsuScreen {
 
     const float input_box_height = 30.f;
     const float button_height = 26.f;
+
+    UString away_msg;
+    UString tab_completion_prefix;
+    UString tab_completion_match;
 };

+ 58 - 35
src/App/Osu/ChatLink.cpp

@@ -11,6 +11,7 @@
 #include "RoomScreen.h"
 #include "SongBrowser/SongBrowser.h"
 #include "TooltipOverlay.h"
+#include "UIUserContextMenu.h"
 
 ChatLink::ChatLink(float xPos, float yPos, float xSize, float ySize, UString link, UString label)
     : CBaseUILabel(xPos, yPos, xSize, ySize, link, label) {
@@ -34,7 +35,34 @@ void ChatLink::mouse_update(bool *propagate_clicks) {
     }
 }
 
+void ChatLink::open_beatmap_link(i32 map_id, i32 set_id) {
+    if(osu->getSongBrowser()->isVisible()) {
+        osu->getSongBrowser()->map_autodl = map_id;
+        osu->getSongBrowser()->set_autodl = set_id;
+    } else if(osu->getMainMenu()->isVisible()) {
+        osu->toggleSongBrowser();
+        osu->getSongBrowser()->map_autodl = map_id;
+        osu->getSongBrowser()->set_autodl = set_id;
+    } else {
+        env->openURLInDefaultBrowser(m_link);
+    }
+}
+
 void ChatLink::onMouseUpInside() {
+    std::string link_str = m_link.toUtf8();
+    std::smatch match;
+
+    // This lazy escaping is only good for endpoint URLs, not anything more serious
+    UString escaped_endpoint;
+    for(int i = 0; i < bancho.endpoint.length(); i++) {
+        if(bancho.endpoint[i] == L'.') {
+            escaped_endpoint.append("\\.");
+        } else {
+            escaped_endpoint.append(bancho.endpoint[i]);
+        }
+    }
+
+    // Detect multiplayer invite links
     if(m_link.startsWith("osump://")) {
         if(osu->m_room->isVisible()) {
             osu->getNotificationOverlay()->addNotification("You are already in a multiplayer room.");
@@ -43,53 +71,48 @@ void ChatLink::onMouseUpInside() {
 
         // If the password has a space in it, parsing will break, but there's no way around it...
         // osu!stable also considers anything after a space to be part of the lobby title :(
-        std::regex password_regex("osump://(\\d+)/(\\S*)");
-        std::string invite_str = m_link.toUtf8();
-        std::smatch match;
-        std::regex_search(invite_str, match, password_regex);
+        std::regex_search(link_str, match, std::regex("osump://(\\d+)/(\\S*)"));
         u32 invite_id = strtoul(match.str(1).c_str(), NULL, 10);
         UString password = match.str(2).c_str();
         osu->m_lobby->joinRoom(invite_id, password);
         return;
     }
 
-    // This lazy escaping is only good for endpoint URLs, not anything more serious
-    UString escaped_endpoint;
-    for(int i = 0; i < bancho.endpoint.length(); i++) {
-        if(bancho.endpoint[i] == L'.') {
-            escaped_endpoint.append("\\.");
-        } else {
-            escaped_endpoint.append(bancho.endpoint[i]);
-        }
+    // Detect user links
+    // https:\/\/(osu\.)?akatsuki\.gg\/u(sers)?\/(\d+)
+    UString user_pattern = "https://(osu\\.)?";
+    user_pattern.append(escaped_endpoint);
+    user_pattern.append("/u(sers)?/(\\d+)");
+    if(std::regex_search(link_str, match, std::regex(user_pattern.toUtf8()))) {
+        i32 user_id = std::stoi(match.str(3));
+        osu->m_user_actions->open(user_id);
+        return;
     }
 
-    // https:\/\/(akatsuki.gg|osu.ppy.sh)\/b(eatmapsets\/\d+#(osu)?)?\/(\d+)
-    UString map_pattern = "https://(osu\\.";
+    // Detect beatmap links
+    // https:\/\/((osu\.)?akatsuki\.gg|osu\.ppy\.sh)\/b(eatmaps)?\/(\d+)
+    UString map_pattern = "https://((osu\\.)?";
     map_pattern.append(escaped_endpoint);
-    map_pattern.append("|osu.ppy.sh)/b(eatmapsets/(\\d+)#(osu)?)?/(\\d+)");
-    std::wregex map_regex(map_pattern.wc_str());
-
-    std::wstring link_str = m_link.wc_str();
-    std::wsmatch match;
-    if(std::regex_search(link_str, match, map_regex)) {
-        i32 map_id = std::stoi(std::wstring_convert<std::codecvt_utf8<wchar_t>>().to_bytes(match.str(5)));
-        i32 set_id = 0;
-        if(match[3].matched) {
-            // Set ID doesn't match if the URL only contains the map ID
-            set_id = std::stoi(std::wstring_convert<std::codecvt_utf8<wchar_t>>().to_bytes(match.str(3)));
-        }
+    map_pattern.append("|osu\\.ppy\\.sh)/b(eatmaps)?/(\\d+)");
+    if(std::regex_search(link_str, match, std::regex(map_pattern.toUtf8()))) {
+        i32 map_id = std::stoi(match.str(4));
+        open_beatmap_link(map_id, 0);
+        return;
+    }
 
-        if(osu->getSongBrowser()->isVisible()) {
-            osu->getSongBrowser()->map_autodl = map_id;
-            osu->getSongBrowser()->set_autodl = set_id;
-        } else if(osu->getMainMenu()->isVisible()) {
-            osu->toggleSongBrowser();
-            osu->getSongBrowser()->map_autodl = map_id;
-            osu->getSongBrowser()->set_autodl = set_id;
-        } else {
-            env->openURLInDefaultBrowser(m_link);
+    // Detect beatmapset links
+    // https:\/\/((osu\.)?akatsuki\.gg|osu\.ppy\.sh)\/beatmapsets\/(\d+)(#osu\/(\d+))?
+    UString set_pattern = "https://((osu\\.)?";
+    set_pattern.append(escaped_endpoint);
+    set_pattern.append("|osu\\.ppy\\.sh)/beatmapsets/(\\d+)(#osu/(\\d+))?");
+    if(std::regex_search(link_str, match, std::regex(set_pattern.toUtf8()))) {
+        i32 set_id = std::stoi(match.str(3));
+        i32 map_id = 0;
+        if(match[5].matched) {
+            map_id = std::stoi(match.str(5));
         }
 
+        open_beatmap_link(map_id, set_id);
         return;
     }
 

+ 2 - 0
src/App/Osu/ChatLink.h

@@ -10,5 +10,7 @@ class ChatLink : public CBaseUILabel {
     virtual void mouse_update(bool *propagate_clicks);
     virtual void onMouseUpInside();
 
+    void open_beatmap_link(i32 map_id, i32 set_id);
+
     UString m_link;
 };

+ 13 - 0
src/App/Osu/Database.cpp

@@ -801,6 +801,19 @@ DatabaseBeatmap *Database::getBeatmapDifficulty(i32 map_id) {
     return NULL;
 }
 
+DatabaseBeatmap *Database::getBeatmapSet(i32 set_id) {
+    if(!isFinished()) return NULL;
+
+    for(size_t i = 0; i < m_databaseBeatmaps.size(); i++) {
+        DatabaseBeatmap *beatmap = m_databaseBeatmaps[i];
+        if(beatmap->getSetID() == set_id) {
+            return beatmap;
+        }
+    }
+
+    return NULL;
+}
+
 std::string Database::parseLegacyCfgBeatmapDirectoryParameter() {
     // get BeatmapDirectory parameter from osu!.<OS_USERNAME>.cfg
     debugLog("Database::parseLegacyCfgBeatmapDirectoryParameter() : username = %s\n", env->getUsername().toUtf8());

+ 1 - 0
src/App/Osu/Database.h

@@ -89,6 +89,7 @@ class Database {
     inline const std::vector<DatabaseBeatmap *> getDatabaseBeatmaps() const { return m_databaseBeatmaps; }
     DatabaseBeatmap *getBeatmapDifficulty(const MD5Hash &md5hash);
     DatabaseBeatmap *getBeatmapDifficulty(i32 map_id);
+    DatabaseBeatmap *getBeatmapSet(i32 set_id);
 
     inline std::unordered_map<MD5Hash, std::vector<FinishedScore>> *getScores() { return &m_scores; }
     inline const std::vector<SCORE_SORTING_METHOD> &getScoreSortingMethods() const { return m_scoreSortingMethods; }

+ 1 - 1
src/App/Osu/MainMenu.cpp

@@ -769,7 +769,7 @@ void MainMenu::draw(Graphics *g) {
     // neosu/server logo
     {
         auto logo = logo_img;
-        if(bancho.server_icon != NULL && bancho.server_icon->isReady()) {
+        if(bancho.server_icon != NULL && bancho.server_icon->isReady() && convar->getConVarByName("main_menu_use_server_logo")->getBool()) {
             logo = bancho.server_icon;
         }
 

+ 24 - 19
src/App/Osu/OptionsMenu.cpp

@@ -1326,6 +1326,9 @@ OptionsMenu::OptionsMenu() : ScreenBackable() {
     logInButton->setColor(0xff00ff00);
     logInButton->setTextColor(0xffffffff);
 
+    addSubSection("Detail settings");
+    addCheckbox("Replace main menu logo with server logo", convar->getConVarByName("main_menu_use_server_logo"));
+
     addSubSection("Integration");
     addCheckbox("Rich Presence (Discord + Steam)",
                 "Shows your current game state in your friends' friendslists.\ne.g.: Playing Gavin G - Reach Out "
@@ -2411,29 +2414,31 @@ void OptionsMenu::onSkinSelect() {
     skinFolder.append(convar->getConVarByName("osu_folder_sub_skins")->getString());
     std::vector<std::string> skinFolders = env->getFoldersInFolder(skinFolder.toUtf8());
 
-    // Sort skins only by alphanum characters, ignore the others
-    std::sort(skinFolders.begin(), skinFolders.end(), [](std::string a, std::string b) {
-        int i = 0;
-        int j = 0;
-        while(i < a.length() && j < b.length()) {
-            if(!isalnum(a[i])) {
+    if(convar->getConVarByName("sort_skins_by_name")->getBool()) {
+        // Sort skins only by alphanum characters, ignore the others
+        std::sort(skinFolders.begin(), skinFolders.end(), [](std::string a, std::string b) {
+            int i = 0;
+            int j = 0;
+            while(i < a.length() && j < b.length()) {
+                if(!isalnum(a[i])) {
+                    i++;
+                    continue;
+                }
+                if(!isalnum(b[j])) {
+                    j++;
+                    continue;
+                }
+                char la = tolower(a[i]);
+                char lb = tolower(b[j]);
+                if(la != lb) return la < lb;
+
                 i++;
-                continue;
-            }
-            if(!isalnum(b[j])) {
                 j++;
-                continue;
             }
-            char la = tolower(a[i]);
-            char lb = tolower(b[j]);
-            if(la != lb) return la < lb;
 
-            i++;
-            j++;
-        }
-
-        return false;
-    });
+            return false;
+        });
+    }
 
     if(skinFolders.size() > 0) {
         m_contextMenu->setPos(m_skinSelectLocalButton->getPos());

+ 3 - 1
src/App/Osu/Osu.cpp

@@ -59,7 +59,7 @@ Osu *osu = NULL;
 
 // release configuration
 ConVar auto_update("auto_update", true, FCVAR_DEFAULT);
-ConVar osu_version("osu_version", 35.06f, FCVAR_DEFAULT | FCVAR_HIDDEN);
+ConVar osu_version("osu_version", 35.07f, FCVAR_DEFAULT | FCVAR_HIDDEN);
 
 #ifdef _DEBUG
 ConVar osu_debug("osu_debug", true, FCVAR_DEFAULT);
@@ -136,6 +136,7 @@ ConVar flashlight_follow_delay("flashlight_follow_delay", 0.120f, FCVAR_LOCKED);
 ConVar flashlight_always_hard("flashlight_always_hard", false, FCVAR_DEFAULT,
                               "always use 200+ combo flashlight radius");
 
+ConVar main_menu_use_server_logo("main_menu_use_server_logo", true, FCVAR_DEFAULT);
 ConVar start_first_main_menu_song_at_preview_point("start_first_main_menu_song_at_preview_point", false, FCVAR_DEFAULT);
 ConVar nightcore_enjoyer("nightcore_enjoyer", false, FCVAR_DEFAULT,
                          "automatically select nightcore when speed modifying");
@@ -146,6 +147,7 @@ ConVar normalize_loudness("normalize_loudness", false, FCVAR_DEFAULT, "normalize
 ConVar restart_sound_engine_before_playing("restart_sound_engine_before_playing", false, FCVAR_DEFAULT,
                                            "jank fix for users who experience sound issues after playing for a while");
 ConVar instafade("instafade", false, FCVAR_DEFAULT, "don't draw hitcircle fadeout animations");
+ConVar sort_skins_by_name("sort_skins_by_name", true, FCVAR_DEFAULT, "set to false to use old behavior");
 
 ConVar use_https("use_https", true, FCVAR_DEFAULT);
 ConVar mp_server("mp_server", "akatsuki.gg", FCVAR_DEFAULT);

+ 48 - 0
src/App/Osu/SongBrowser/SongBrowser.cpp

@@ -943,6 +943,54 @@ void SongBrowser::mouse_update(bool *propagate_clicks) {
             osu->getNotificationOverlay()->addNotification(text);
         } else if(beatmap != NULL) {
             osu->m_songBrowser2->onDifficultySelected(beatmap, false);
+            osu->m_songBrowser2->selectSelectedBeatmapSongButton();
+            map_autodl = 0;
+            set_autodl = 0;
+        }
+    } else if(set_autodl) {
+        auto beatmapset = getDatabase()->getBeatmapSet(set_autodl);
+        if(beatmapset == NULL) {
+            float progress = -1.f;
+            download_beatmapset(set_autodl, &progress);
+            if(progress == -1.f) {
+                auto error_str = UString::format("Failed to download Beatmapset #%d :(", set_autodl);
+                osu->getNotificationOverlay()->addNotification(error_str);
+                map_autodl = 0;
+                set_autodl = 0;
+            } else if(progress < 1.f) {
+                // TODO @kiwec: this notification format is jank & laggy
+                auto text = UString::format("Downloading... %.2f%%", progress * 100.f);
+                osu->getNotificationOverlay()->addNotification(text);
+            } else {
+                // Pasted from Downloader::download_beatmap
+                auto mapset_path = UString::format(MCENGINE_DATA_DIR "maps/%d/", set_autodl);
+                // XXX: Make a permanent database for auto-downloaded songs, so we can load them like osu!.db's
+                osu->m_songBrowser2->getDatabase()->addBeatmap(mapset_path.toUtf8());
+                osu->m_songBrowser2->updateSongButtonSorting();
+                debugLog("Finished loading beatmapset %d.\n", set_autodl);
+
+                beatmapset = getDatabase()->getBeatmapSet(set_autodl);
+            }
+        }
+
+        if(beatmapset != NULL) {
+            // Just picking the hardest diff for now
+            DatabaseBeatmap *best_diff = NULL;
+            const std::vector<DatabaseBeatmap *> &diffs = beatmapset->getDifficulties();
+            for(size_t d = 0; d < diffs.size(); d++) {
+                DatabaseBeatmap *diff = diffs[d];
+                if(!best_diff || diff->getStarsNomod() > best_diff->getStarsNomod()) {
+                    best_diff = diff;
+                }
+            }
+
+            if(best_diff == NULL) {
+                osu->getNotificationOverlay()->addNotification("Beatmapset has no difficulties :/");
+            } else {
+                osu->m_songBrowser2->onDifficultySelected(best_diff, false);
+                osu->m_songBrowser2->selectSelectedBeatmapSongButton();
+            }
+
             map_autodl = 0;
             set_autodl = 0;
         }

+ 34 - 0
src/Engine/Sound.cpp

@@ -198,6 +198,7 @@ void Sound::destroy() {
 void Sound::setPosition(double percent) { return setPositionMS(clamp<f64>(percent, 0.0, 1.0) * m_length); }
 
 void Sound::setPositionMS(unsigned long ms) {
+    if(ms == 0) return setPositionMS_fast(ms);
     if(!m_bReady || ms > getLengthMS()) return;
     if(!m_bStream) {
         engine->showMessageError("Programmer Error", "Called setPositionMS on a sample!");
@@ -268,6 +269,39 @@ void Sound::setPositionMS(unsigned long ms) {
     }
 }
 
+// Inaccurate but fast seeking, to use at song select
+void Sound::setPositionMS_fast(u32 ms) {
+    if(!m_bReady || ms > getLengthMS()) return;
+    if(!m_bStream) {
+        engine->showMessageError("Programmer Error", "Called setPositionMS_fast on a sample!");
+        return;
+    }
+
+    i64 target_pos = BASS_ChannelSeconds2Bytes(m_stream, ms / 1000.0);
+    if(target_pos < 0) {
+        debugLog("setPositionMS_fast: error %d while calling BASS_ChannelSeconds2Bytes\n", BASS_ErrorGetCode());
+        return;
+    }
+
+    if(isPlaying()) {
+        if(!BASS_Mixer_ChannelSetPosition(m_stream, target_pos, BASS_POS_BYTE | BASS_POS_MIXER_RESET)) {
+            if(Osu::debug->getBool()) {
+                debugLog("Sound::setPositionMS_fast( %lu ) BASS_ChannelSetPosition() error %i on file %s\n", ms,
+                         BASS_ErrorGetCode(), m_sFilePath.c_str());
+            }
+        }
+
+        m_fLastPlayTime = m_fChannelCreationTime - ((f64)ms / 1000.0);
+    } else {
+        if(!BASS_ChannelSetPosition(m_stream, target_pos, BASS_POS_BYTE | BASS_POS_FLUSH)) {
+            if(Osu::debug->getBool()) {
+                debugLog("Sound::setPositionMS( %lu ) BASS_ChannelSetPosition() error %i on file %s\n", ms,
+                         BASS_ErrorGetCode(), m_sFilePath.c_str());
+            }
+        }
+    }
+}
+
 void Sound::setVolume(float volume) {
     if(!m_bReady) return;
 

+ 2 - 0
src/Engine/Sound.h

@@ -30,6 +30,8 @@ class Sound : public Resource {
 
     void setPosition(double percent);
     void setPositionMS(unsigned long ms);
+    void setPositionMS_fast(u32 ms);
+
     void setVolume(float volume);
     void setSpeed(float speed);
     void setFrequency(float frequency);

+ 4 - 0
src/Engine/SoundEngine.cpp

@@ -492,6 +492,10 @@ bool SoundEngine::init_bass_mixer(OUTPUT_DEVICE device) {
         return false;
     }
 
+    // Disable buffering to lower latency on regular BASS output
+    // This has no effect on ASIO/WASAPI since for those the mixer is a decode stream
+    BASS_ChannelSetAttribute(g_bassOutputMixer, BASS_ATTRIB_BUFFER, 0.f);
+
     // Switch to "No sound" device for all future sound processing
     // Only g_bassOutputMixer will be output to the actual device!
     BASS_SetDevice(0);

+ 1 - 1
src/GUI/CBaseUIScrollView.cpp

@@ -519,7 +519,7 @@ void CBaseUIScrollView::updateScrollbars() {
         const float verticalPercent = clamp<float>(rawVerticalPercent, 0.0f, 1.0f);
 
         const float verticalHeightPercent = (m_vSize.y - (verticalBlockWidth * 2)) / m_vScrollSize.y;
-        const float verticalBlockHeight = clamp<float>(
+        const float verticalBlockHeight = bad_clamp<float>(
             max(verticalHeightPercent * m_vSize.y, verticalBlockWidth) * overscroll, verticalBlockWidth, m_vSize.y);
 
         m_verticalScrollbar =

+ 0 - 47
src/GUI/CBaseUITextbox.cpp

@@ -268,51 +268,6 @@ void CBaseUITextbox::mouse_update(bool *propagate_clicks) {
     // handle context menu
     if(!mright && m_bContextMouse && isMouseInside()) {
         m_bContextMouse = false;
-        /*
-        engine->getMouse()->setCursorType(CURSORTYPE::CURSOR_NORMAL);
-        cmenu->begin();
-        {
-                cmenu->addItem("Clear", 5);
-                cmenu->addSeparator();
-                cmenu->addItem("Paste", 4);
-
-                if (hasSelectedText())
-                {
-                        cmenu->addItem("Copy", 3);
-                        cmenu->addItem("Cut", 2);
-                        cmenu->addSeparator();
-                        cmenu->addItem("Delete", 1);
-                }
-        }
-        const int item = cmenu->end();
-
-        switch (item)
-        {
-        case 5: // clear
-                clear();
-                break;
-
-        case 4: // paste
-                handleDeleteSelectedText();
-                insertTextFromClipboard();
-                break;
-
-        case 3: // copy
-                //envDebugLog("selected copy text: %s\n",getSelectedText().toUtf8());
-                env->setClipBoardText(getSelectedText());
-                break;
-
-        case 2: // cut
-                env->setClipBoardText(getSelectedText());
-                handleDeleteSelectedText();
-                //envDebugLog("selected cut text: %s\n",getSelectedText().toUtf8());
-                break;
-
-        case 1: // delete
-                handleDeleteSelectedText();
-                break;
-        }
-        */
     }
 }
 
@@ -658,9 +613,7 @@ void CBaseUITextbox::updateTextPos() {
     if(m_iTextJustification == 0) {
         if((m_iTextAddX + m_fTextScrollAddX) > ui_textbox_text_offset_x.getInt()) {
             if(hasSelectedText() && m_iCaretPosition == 0) {
-                // TODO: animations? don't like it as it is
                 m_fTextScrollAddX = ui_textbox_text_offset_x.getInt() - m_iTextAddX;
-                /// animation->moveSmoothEnd(&m_fTextScrollAddX, ui_textbox_text_offset_x.getInt() - m_iTextAddX, 1);
             } else
                 m_fTextScrollAddX = ui_textbox_text_offset_x.getInt() - m_iTextAddX;
         }

+ 5 - 5
src/GUI/CBaseUITextbox.h

@@ -82,6 +82,11 @@ class CBaseUITextbox : public CBaseUIElement {
 
     bool is_password = false;
 
+    UString m_sText;
+    int m_iCaretPosition;
+    void tickCaret();
+    void updateTextPos();
+
    protected:
     virtual void drawText(Graphics *g);
 
@@ -92,19 +97,15 @@ class CBaseUITextbox : public CBaseUIElement {
     virtual void onMouseUpOutside();
     virtual void onResized();
 
-    void tickCaret();
     void handleCaretKeyboardMove();
     void handleCaretKeyboardDelete();
     void updateCaretX();
 
     void handleDeleteSelectedText();
     void insertTextFromClipboard();
-    void updateTextPos();
     void deselectText();
     UString getSelectedText();
 
-    UString m_sText;
-
     McFont *m_font;
 
     Color m_textColor;
@@ -124,7 +125,6 @@ class CBaseUITextbox : public CBaseUIElement {
     int m_iTextAddX;
     int m_iTextAddY;
     float m_fTextScrollAddX;
-    int m_iCaretPosition;
     int m_iCaretX;
     int m_iCaretWidth;
     int m_iTextJustification;

+ 8 - 0
src/Util/UString.cpp

@@ -668,6 +668,14 @@ bool UString::endsWith(const UString &ustr) const {
     return true;
 }
 
+bool UString::startsWithIgnoreCase(const UString &ustr) const {
+    if(mLength < ustr.mLength) return false;
+    for(int i = 0; i < ustr.mLength; i++) {
+        if(std::towlower(mUnicode[i]) != std::towlower(ustr.mUnicode[i])) return false;
+    }
+    return true;
+}
+
 bool UString::equalsIgnoreCase(const UString &ustr) const {
     if(mLength != ustr.mLength) return false;
 

+ 1 - 0
src/Util/UString.h

@@ -90,6 +90,7 @@ class UString {
     bool operator<(const UString &ustr) const;
 
     bool startsWith(const UString &ustr) const;
+    bool startsWithIgnoreCase(const UString &ustr) const;
     bool endsWith(const UString &ustr) const;
     bool equalsIgnoreCase(const UString &ustr) const;
     bool lessThanIgnoreCase(const UString &ustr) const;

+ 6 - 0
src/Util/cbase.h

@@ -135,6 +135,12 @@ typedef unsigned char COLORPART;
 
 // UTIL
 
+// "bad" because a can be lower than b
+template <class T>
+inline T bad_clamp(T x, T a, T b) {
+    return x < a ? a : (x > b ? b : x);
+}
+
 template <class T>
 inline T lerp(T x1, T x2, T percent) {
     return x1 * (1 - percent) + x2 * percent;