diff --git a/loader/include/Geode/ui/GeodeUI.hpp b/loader/include/Geode/ui/GeodeUI.hpp index 99509304..de817fb5 100644 --- a/loader/include/Geode/ui/GeodeUI.hpp +++ b/loader/include/Geode/ui/GeodeUI.hpp @@ -36,7 +36,9 @@ namespace geode { */ GEODE_DLL cocos2d::CCNode* createModLogo(Mod* mod); /** - * Create a logo sprite for an index item + * Create a logo sprite for a mod downloaded from the Geode servers. The + * logo is initially a loading circle, with the actual sprite downloaded + * asynchronously */ - // GEODE_DLL cocos2d::CCNode* createIndexItemLogo(IndexItemHandle item); + GEODE_DLL cocos2d::CCNode* createServerModLogo(std::string const& id); } diff --git a/loader/resources/globe.png b/loader/resources/globe.png new file mode 100644 index 00000000..ed94f2b3 Binary files /dev/null and b/loader/resources/globe.png differ diff --git a/loader/src/server/Server.cpp b/loader/src/server/Server.cpp index 33792c35..e1ffe5b7 100644 --- a/loader/src/server/Server.cpp +++ b/loader/src/server/Server.cpp @@ -9,6 +9,32 @@ using namespace server; #define GEODE_GD_VERSION_STRINGIFY_2(version) GEODE_GD_VERSION_STRINGIFY(version) #define GEODE_GD_VERSION_STR GEODE_GD_VERSION_STRINGIFY_2(GEODE_GD_VERSION) +static void parseServerError(auto reject, auto error) { + // The server should return errors as `{ "error": "...", "payload": "" }` + if (auto json = error.json()) { + reject(ServerError( + "Error code: {}; details: {}", + error.code(), json.unwrap().template get("error") + )); + } + // But if we get something else for some reason, return that + else { + reject(ServerError( + "Error code: {}; details: {}", + error.code(), error.string().unwrapOr("Unknown (not a valid string)") + )); + } +} + +static void parseServerProgress(auto progress, auto prog, auto msg) { + if (auto per = prog.downloadProgress()) { + progress({ msg, static_cast(*per) }); + } + else { + progress({ msg }); + } +} + const char* server::sortToString(ModsSort sorting) { switch (sorting) { default: @@ -112,12 +138,14 @@ Result ServerModMetadata::parse(matjson::Value const& raw) { root.has("about").into(res.about); root.has("changelog").into(res.changelog); + std::vector developerNames; for (auto item : root.needs("developers").iterate()) { auto obj = item.obj(); auto dev = ServerDeveloper(); obj.needs("username").into(dev.username); obj.needs("display_name").into(dev.displayName); res.developers.push_back(dev); + developerNames.push_back(dev.displayName); } for (auto item : root.needs("versions").iterate()) { auto versionRes = ServerModVersion::parse(item.json()); @@ -125,6 +153,7 @@ Result ServerModMetadata::parse(matjson::Value const& raw) { auto version = versionRes.unwrap(); version.metadata.setDetails(res.about); version.metadata.setChangelog(res.changelog); + version.metadata.setDevelopers(developerNames); res.versions.push_back(version); } else { @@ -197,7 +226,9 @@ ServerPromise server::getMods(ModsQuery query) { if (query.tags.size()) { req.param("tags", ranges::join(query.tags, ",")); } - req.param("featured", query.featuredOnly ? "true" : "false"); + if (query.featured) { + req.param("featured", query.featured.value() ? "true" : "false"); + } req.param("sort", sortToString(query.sorting)); if (query.developer) { req.param("developer", *query.developer); @@ -228,28 +259,28 @@ ServerPromise server::getMods(ModsQuery query) { if (error.code() == 404) { return resolve(ServerModsList()); } - // The server should return errors as `{ "error": "...", "payload": "" }` - if (auto json = error.json()) { - reject(ServerError( - "Error code: {}; details: {}", - error.code(), json.unwrap().template get("error") - )); - } - // But if we get something else for some reason, return that - else { - reject(ServerError( - "Error code: {}; details: {}", - error.code(), error.string().unwrapOr("Unknown (not a valid string)") - )); - } + parseServerError(reject, error); }) .progress([progress](auto prog) { - if (auto per = prog.downloadProgress()) { - progress({ "Downloading mods", static_cast(*per) }); - } - else { - progress({ "Downloading mods" }); - } + parseServerProgress(progress, prog, "Downloading mods"); + }) + .link(cancel); + }); +} + +ServerPromise server::getModLogo(std::string const& id) { + auto req = web::WebRequest(); + req.param("id", id); + return ServerPromise([req = std::move(req), id](auto resolve, auto reject, auto progress, auto cancel) mutable { + req.get(getServerAPIBaseURL() + "/mods/" + id + "/logo") + .then([resolve](auto response) { + resolve(response.data()); + }) + .expect([reject](auto error) { + parseServerError(reject, error); + }) + .progress([progress, id](auto prog) { + parseServerProgress(progress, prog, "Downloading logo for " + id); }) .link(cancel); }); diff --git a/loader/src/server/Server.hpp b/loader/src/server/Server.hpp index 78bef034..7d578ac4 100644 --- a/loader/src/server/Server.hpp +++ b/loader/src/server/Server.hpp @@ -54,7 +54,7 @@ namespace server { std::optional query; std::unordered_set platforms = { GEODE_PLATFORM_TARGET }; std::unordered_set tags; - bool featuredOnly = false; + std::optional featured; ModsSort sorting = ModsSort::Downloads; std::optional developer; size_t page = 0; @@ -77,4 +77,5 @@ namespace server { std::string getServerAPIBaseURL(); ServerPromise getMods(ModsQuery query); + ServerPromise getModLogo(std::string const& id); } diff --git a/loader/src/ui/GeodeUI.cpp b/loader/src/ui/GeodeUI.cpp index 9190469b..7c83fc31 100644 --- a/loader/src/ui/GeodeUI.cpp +++ b/loader/src/ui/GeodeUI.cpp @@ -1,10 +1,10 @@ #include "mods/ModsLayer.hpp" - #include #include #include #include +#include void geode::openModsList() { ModsLayer::scene(); @@ -65,26 +65,95 @@ void geode::openSettingsPopup(Mod* mod) { } } -CCNode* geode::createDefaultLogo() { - CCNode* spr = CCSprite::createWithSpriteFrameName("no-logo.png"_spr); - if (!spr) { - spr = CCLabelBMFont::create("OwO", "goldFont.fnt"); +class ModLogoSprite : public CCNode { +protected: + std::string m_modID; + CCNode* m_sprite = nullptr; + EventListener> m_listener; + + bool init(std::string const& id, bool fetch) { + if (!CCNode::init()) + return false; + + this->setAnchorPoint({ .5f, .5f }); + this->setContentSize({ 50, 50 }); + + m_modID = id; + m_listener.bind(this, &ModLogoSprite::onFetch); + + // Load from Resources + if (!fetch) { + this->setSprite(id == "geode.loader" ? + CCSprite::createWithSpriteFrameName("geode-logo.png"_spr) : + CCSprite::create(fmt::format("{}/logo.png", id).c_str()) + ); + } + // Asynchronously fetch from server + else { + this->setSprite(CCSprite::create("loadingCircle.png")); + static_cast(m_sprite)->setBlendFunc({ GL_ONE, GL_ONE }); + m_sprite->runAction(CCRepeatForever::create(CCRotateBy::create(1.f, 360.f))); + m_listener.setFilter(server::getModLogo(id).listen()); + } + + return true; } - return spr; + + void setSprite(CCNode* sprite) { + // Remove any existing sprite + if (m_sprite) { + m_sprite->removeFromParent(); + } + // Fallback to default logo if the sprite is null + if (!sprite) { + sprite = CCSprite::createWithSpriteFrameName("no-logo.png"_spr); + } + // Fallback to lobotomy if Geode sprites are missing + if (!sprite) { + sprite = CCSprite::createWithSpriteFrameName("difficulty_02_btn_001.png"); + } + // Set sprite and scale it to node size + m_sprite = sprite; + limitNodeSize(m_sprite, m_obContentSize, 99.f, .05f); + this->addChildAtPosition(m_sprite, Anchor::Center); + } + + void onFetch(PromiseEvent* event) { + // Set default sprite on error + if (event->getReject()) { + this->setSprite(nullptr); + } + else if (auto data = event->getResolve()) { + auto image = Ref(new CCImage()); + image->initWithImageData(const_cast(data->data()), data->size()); + + auto texture = CCTextureCache::get()->addUIImage(image, m_modID.c_str()); + this->setSprite(CCSprite::createWithTexture(texture)); + } + } + +public: + static ModLogoSprite* create(std::string const& id, bool fetch) { + auto ret = new ModLogoSprite(); + if (ret && ret->init(id, fetch)) { + ret->autorelease(); + return ret; + } + CC_SAFE_DELETE(ret); + return nullptr; + } +}; + +CCNode* geode::createDefaultLogo() { + return ModLogoSprite::create("", false); } CCNode* geode::createModLogo(Mod* mod) { - CCNode* ret = nullptr; - if (mod == Mod::get()) { - ret = CCSprite::createWithSpriteFrameName("geode-logo.png"_spr); - } - else { - ret = CCSprite::create(fmt::format("{}/logo.png", mod->getID()).c_str()); - } - if (!ret) { - ret = createDefaultLogo(); - } - return ret; + return ModLogoSprite::create(mod->getID(), false); +} + +CCNode* geode::createServerModLogo(std::string const& id) { + return ModLogoSprite::create(id, true); } // CCNode* geode::createIndexItemLogo(IndexItemHandle item) { diff --git a/loader/src/ui/mods/ModItem.cpp b/loader/src/ui/mods/ModItem.cpp index 26948a3a..b27b4c1e 100644 --- a/loader/src/ui/mods/ModItem.cpp +++ b/loader/src/ui/mods/ModItem.cpp @@ -138,7 +138,7 @@ ModMetadata ServerModItem::getMetadata() const { } CCNode* ServerModItem::createModLogo() const { - return CCSprite::create("loadingCircle.png"); + return createServerModLogo(m_metadata.id); } bool ServerModItem::wantsRestart() const { diff --git a/loader/src/ui/mods/ModListSource.cpp b/loader/src/ui/mods/ModListSource.cpp index edafd3f1..d26e6cef 100644 --- a/loader/src/ui/mods/ModListSource.cpp +++ b/loader/src/ui/mods/ModListSource.cpp @@ -8,11 +8,57 @@ static size_t ceildiv(size_t a, size_t b) { return a / b + (a % b != 0); } -#define GEODE_GD_VERSION_STRINGIFY(version) # version -#define GEODE_GD_VERSION_STRINGIFY_2(version) GEODE_GD_VERSION_STRINGIFY(version) -#define GEODE_GD_VERSION_STR GEODE_GD_VERSION_STRINGIFY_2(GEODE_GD_VERSION) +static auto loadInstalledModsPage(size_t page) { + return ModListSource::ProviderPromise([page](auto resolve, auto, auto, auto const&) { + Loader::get()->queueInMainThread([page, resolve = std::move(resolve)] { + auto content = ModListSource::Page(); + auto all = Loader::get()->getAllMods(); + for ( + size_t i = page * PAGE_SIZE; + i < all.size() && i < (page + 1) * PAGE_SIZE; + i += 1 + ) { + content.push_back(InstalledModItem::create(all.at(i))); + } + resolve({ content, all.size() }); + }); + }); +} + +static auto loadServerModsPage(size_t page, bool featuredOnly) { + return ModListSource::ProviderPromise([page, featuredOnly](auto resolve, auto reject, auto progress, auto cancelled) { + server::getMods(server::ModsQuery { + .featured = featuredOnly ? std::optional(true) : std::nullopt, + .page = page, + .pageSize = PAGE_SIZE, + }) + .then([resolve, reject](server::ServerModsList list) { + if (list.totalModCount == 0) { + return reject(ModListSource::LoadPageError("No mods found :(")); + } + auto content = ModListSource::Page(); + for (auto mod : list.mods) { + content.push_back(ServerModItem::create(mod)); + } + resolve({ content, list.totalModCount }); + }) + .expect([reject](auto error) { + reject(ModListSource::LoadPageError("Error loading mods", error.details)); + }) + .progress([progress](auto prog) { + progress(prog.percentage); + }) + .link(cancelled); + }); +} typename ModListSource::PagePromise ModListSource::loadPage(size_t page, bool update) { + // Return a generic "Coming soon" message if there's no provider set + if (!m_provider) { + return PagePromise([this, page](auto, auto reject) { + reject("Coming soon! ;)"); + }); + } if (!update && m_cachedPages.contains(page)) { return PagePromise([this, page](auto resolve, auto) { Loader::get()->queueInMainThread([this, page, resolve] { @@ -22,10 +68,11 @@ typename ModListSource::PagePromise ModListSource::loadPage(size_t page, bool up } m_cachedPages.erase(page); return PagePromise([this, page](auto resolve, auto reject, auto progress, auto cancelled) { - this->reloadPage(page) + m_provider(page) .then([page, this, resolve = std::move(resolve)](auto data) { - m_cachedPages.insert({ page, data }); - resolve(data); + m_cachedItemCount = data.second; + m_cachedPages.insert({ page, data.first }); + resolve(data.first); }) .expect([this, reject = std::move(reject)](auto error) { reject(error); @@ -45,69 +92,43 @@ std::optional ModListSource::getItemCount() const { return m_cachedItemCount; } -typename ModListSource::PagePromise InstalledModsList::reloadPage(size_t page) { - m_cachedItemCount = Loader::get()->getAllMods().size(); - return PagePromise([page](auto resolve, auto, auto, auto const&) { - Loader::get()->queueInMainThread([page, resolve = std::move(resolve)] { - auto content = Page(); - auto all = Loader::get()->getAllMods(); - for ( - size_t i = page * PAGE_SIZE; - i < all.size() && i < (page + 1) * PAGE_SIZE; - i += 1 - ) { - content.push_back(InstalledModItem::create(all.at(i))); - } - resolve(content); - }); - }); +ModListSource* ModListSource::create(Provider* provider) { + auto ret = new ModListSource(); + ret->m_provider = provider; + ret->autorelease(); + return ret; } -InstalledModsList* InstalledModsList::get() { - static auto inst = new InstalledModsList(); - return inst; -} +ModListSource* ModListSource::get(ModListSourceType type) { + switch (type) { + default: + case ModListSourceType::Installed: { + static auto inst = ModListSource::create(loadInstalledModsPage); + return inst; + } break; -typename ModListSource::PagePromise FeaturedModsList::reloadPage(size_t page) { - return PagePromise([this, page](auto resolve, auto reject, auto progress, auto cancelled) { - server::getMods(server::ModsQuery { - .page = page, - .pageSize = PAGE_SIZE, - }) - .then([this, resolve, reject](server::ServerModsList list) { - m_cachedItemCount = list.totalModCount; - if (list.totalModCount == 0) { - return reject(LoadPageError("No mods found :(")); - } - auto content = Page(); - for (auto mod : list.mods) { - content.push_back(ServerModItem::create(mod)); - } - resolve(content); - }) - .expect([reject](auto error) { - reject(LoadPageError("Error loading mods", error.details)); - }) - .progress([progress](auto prog) { - progress(prog.percentage); - }) - .link(cancelled); - }); -} + case ModListSourceType::Featured: { + static auto inst = ModListSource::create(+[](size_t page) { + return loadServerModsPage(page, true); + }); + return inst; + } break; -FeaturedModsList* FeaturedModsList::get() { - static auto inst = new FeaturedModsList(); - return inst; -} + case ModListSourceType::Trending: { + static auto inst = ModListSource::create(nullptr); + return inst; + } break; -typename ModListSource::PagePromise ModPacksModsList::reloadPage(size_t page) { - m_cachedItemCount = 0; - return PagePromise([](auto, auto reject) { - reject(LoadPageError("Coming soon! ;)")); - }); -} + case ModListSourceType::ModPacks: { + static auto inst = ModListSource::create(nullptr); + return inst; + } break; -ModPacksModsList* ModPacksModsList::get() { - static auto inst = new ModPacksModsList(); - return inst; + case ModListSourceType::All: { + static auto inst = ModListSource::create(+[](size_t page) { + return loadServerModsPage(page, false); + }); + return inst; + } break; + } } diff --git a/loader/src/ui/mods/ModListSource.hpp b/loader/src/ui/mods/ModListSource.hpp index 4011f757..1bbf8de9 100644 --- a/loader/src/ui/mods/ModListSource.hpp +++ b/loader/src/ui/mods/ModListSource.hpp @@ -6,8 +6,16 @@ using namespace geode::prelude; +enum class ModListSourceType { + Installed, + Featured, + Trending, + ModPacks, + All, +}; + // Handles loading the entries for the mods list -class ModListSource { +class ModListSource : public CCObject { public: struct LoadPageError { std::string message; @@ -24,40 +32,23 @@ public: using PageLoadEventListener = EventListener; using PagePromise = Promise>; + using ProviderPromise = Promise, LoadPageError, std::optional>; + using Provider = ProviderPromise(size_t page); + protected: std::unordered_map m_cachedPages; std::optional m_cachedItemCount; - - // Load/reload a page. This should also set/update the page count - virtual PagePromise reloadPage(size_t page) = 0; + Provider* m_provider = nullptr; public: + // Create a new source with an arbitary provider + static ModListSource* create(Provider* provider); + + // Get a standard source (lazily created static instance) + static ModListSource* get(ModListSourceType type); + // Load page, uses cache if possible unless `update` is true PagePromise loadPage(size_t page, bool update = false); std::optional getPageCount() const; std::optional getItemCount() const; }; - -class InstalledModsList : public ModListSource { -protected: - PagePromise reloadPage(size_t page) override; - -public: - static InstalledModsList* get(); -}; - -class FeaturedModsList : public ModListSource { -protected: - PagePromise reloadPage(size_t page) override; - -public: - static FeaturedModsList* get(); -}; - -class ModPacksModsList : public ModListSource { -protected: - PagePromise reloadPage(size_t page) override; - -public: - static ModPacksModsList* get(); -}; diff --git a/loader/src/ui/mods/ModsLayer.cpp b/loader/src/ui/mods/ModsLayer.cpp index ec7c9862..d201feba 100644 --- a/loader/src/ui/mods/ModsLayer.cpp +++ b/loader/src/ui/mods/ModsLayer.cpp @@ -365,12 +365,12 @@ bool ModsLayer::init() { mainTabs->setAnchorPoint({ .5f, .0f }); mainTabs->setPosition(m_frame->convertToWorldSpace(tabsTop->getPosition() + ccp(0, 10))); - for (auto item : std::initializer_list> { - { "download.png"_spr, "Installed", InstalledModsList::get() }, - { "GJ_bigStar_noShadow_001.png", "Featured", FeaturedModsList::get() }, - { "GJ_sTrendingIcon_001.png", "Trending", nullptr }, - { "gj_folderBtn_001.png", "Mod Packs", ModPacksModsList::get() }, - { "search.png"_spr, "Search", nullptr }, + for (auto item : std::initializer_list> { + { "download.png"_spr, "Installed", ModListSourceType::Installed }, + { "GJ_bigStar_noShadow_001.png", "Featured", ModListSourceType::Featured }, + { "GJ_sTrendingIcon_001.png", "Trending", ModListSourceType::Trending }, + { "gj_folderBtn_001.png", "Mod Packs", ModListSourceType::ModPacks }, + { "globe.png"_spr, "All Mods", ModListSourceType::All }, }) { const CCSize itemSize { 100, 35 }; const CCSize iconSize { 18, 18 }; @@ -403,7 +403,7 @@ bool ModsLayer::init() { spr->addChildAtPosition(title, Anchor::Left, ccp(28, 0), false); auto btn = CCMenuItemSpriteExtra::create(spr, this, menu_selector(ModsLayer::onTab)); - btn->setUserData(std::get<2>(item)); + btn->setTag(static_cast(std::get<2>(item))); mainTabs->addChild(btn); m_tabs.push_back(btn); } @@ -411,7 +411,7 @@ bool ModsLayer::init() { mainTabs->setLayout(RowLayout::create()); this->addChild(mainTabs); - this->gotoTab(); + this->gotoTab(ModListSourceType::Installed); this->setKeypadEnabled(true); cocos::handleTouchPriority(this, true); @@ -419,19 +419,17 @@ bool ModsLayer::init() { return true; } -void ModsLayer::gotoTab(ModListSource* src) { - // Default to installed mods - if (!src) { - src = InstalledModsList::get(); - } - +void ModsLayer::gotoTab(ModListSourceType type) { // Update selected tab for (auto tab : m_tabs) { - auto selected = tab->getUserData() == src; + auto selected = tab->getTag() == static_cast(type); tab->getNormalImage()->getChildByID("disabled-bg")->setVisible(!selected); tab->getNormalImage()->getChildByID("enabled-bg")->setVisible(selected); tab->setEnabled(!selected); } + + auto src = ModListSource::get(type); + // Remove current list from UI (it's Ref'd so it stays in memory) if (m_currentSource) { m_lists.at(m_currentSource)->removeFromParent(); @@ -453,7 +451,7 @@ void ModsLayer::gotoTab(ModListSource* src) { } void ModsLayer::onTab(CCObject* sender) { - this->gotoTab(static_cast(static_cast(sender)->getUserData())); + this->gotoTab(static_cast(sender->getTag())); } void ModsLayer::keyBackClicked() { diff --git a/loader/src/ui/mods/ModsLayer.hpp b/loader/src/ui/mods/ModsLayer.hpp index 21551066..c4dc9710 100644 --- a/loader/src/ui/mods/ModsLayer.hpp +++ b/loader/src/ui/mods/ModsLayer.hpp @@ -17,7 +17,7 @@ using ModListStatus = std::variant m_source; size_t m_page = 0; ScrollLayer* m_list; CCMenu* m_statusContainer; @@ -70,5 +70,5 @@ public: void onBack(CCObject*); void onRefreshList(CCObject*); - void gotoTab(ModListSource* src = nullptr); + void gotoTab(ModListSourceType type); };