diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java index 09500345e2..3b45ce56c9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java @@ -39,6 +39,7 @@ import org.jackhuang.hmcl.util.Holder; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.javafx.BindingMapping; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -186,6 +187,39 @@ public static ObjectBinding skinBinding(YggdrasilService service, }, uuidFallback); } + /// Loads the cached skin texture of an account from the local texture cache. + /// + /// The skin URL stored in the account private data locates the texture file downloaded on an earlier launch, so + /// that the avatar can be displayed immediately without waiting for the profile to be fetched from the network. + /// + /// @param account the account whose cached skin should be loaded + /// @param fallback the texture to return when no skin is cached or the cached texture is unavailable + /// @return the cached skin texture, or `fallback` if it is unavailable + private static LoadedTexture getCachedSkinTexture(Account account, LoadedTexture fallback) { + @Nullable String url = account.getCachedSkinUrl(); + if (StringUtils.isBlank(url)) { + return fallback; + } + + Path file = getTexturePath(new Texture(url, null)); + if (!Files.isRegularFile(file)) { + return fallback; + } + + try (InputStream in = Files.newInputStream(file)) { + Image img = new Image(in); + if (img.isError()) + throw img.getException(); + + @Nullable String model = account.getCachedSkinModel(); + Map metadata = model == null ? emptyMap() : singletonMap("model", model); + return new LoadedTexture(img, metadata); + } catch (Throwable e) { + LOG.warning("Failed to load cached skin texture " + url, e); + return fallback; + } + } + public static ObservableValue skinBinding(Account account) { LoadedTexture uuidFallback = getDefaultSkin(account.getProfileID()); if (account instanceof OfflineAccount) { @@ -229,16 +263,25 @@ public static ObservableValue skinBinding(Account account) { if (texture != null && StringUtils.isNotBlank(texture.url())) { return CompletableFuture.supplyAsync(() -> { try { - return loadTexture(texture); + LoadedTexture loadedTexture = loadTexture(texture); + @Nullable Map metadata = texture.metadata(); + account.setCachedSkin(texture.url(), metadata == null ? null : metadata.get("model")); + return loadedTexture; } catch (Throwable e) { LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e); return uuidFallback; } }, POOL); } + + // A present profile without a skin texture confirms that the account has no skin, so the + // stale cache, if any, is cleared. An empty `textures`, in contrast, may just mean that + // the profile has not been fetched yet. + account.setCachedSkin(null, null); } - return CompletableFuture.completedFuture(uuidFallback); + // Until the profile arrives, keep displaying the skin cached on an earlier launch, if any. + return CompletableFuture.completedFuture(getCachedSkinTexture(account, uuidFallback)); }, uuidFallback); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index 30f3f19424..a7e3ed97c4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -271,7 +271,10 @@ private static Account parseAccount(JsonObject record, boolean portable) { try { AccountID accountID = Account.readAccountID(record); - return factory.fromStorage(record, SettingsManager.getAccountPrivateData(accountID, portable)); + JsonObject privateData = SettingsManager.getAccountPrivateData(accountID, portable); + Account account = factory.fromStorage(record, privateData); + Account.restoreCachedSkin(account, privateData); + return account; } catch (Exception e) { LOG.warning("Failed to load account: " + describeAccountRecord(record), e); return null; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java index 5ff62c427f..1cc3c268d6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java @@ -50,6 +50,12 @@ public abstract class Account implements Observable { /// The serialized account ID property name. public static final String PROPERTY_ACCOUNT_ID = "accountID"; + /// The serialized cached skin URL property name. + private static final String PROPERTY_CACHED_SKIN_URL = "cachedSkinUrl"; + + /// The serialized cached skin model property name. + private static final String PROPERTY_CACHED_SKIN_MODEL = "cachedSkinModel"; + /// The stable ID of this account entry. private final AccountID accountID; @@ -108,6 +114,56 @@ public void writeMetadata(JsonObject metadata) { metadata.addProperty(PROPERTY_ACCOUNT_ID, accountID.toString()); } + /// Cached skin texture URL of this account, or `null` if no skin has been cached. + private @Nullable String cachedSkinUrl; + + /// Cached skin model name (`"slim"` or `"default"`) of this account, or `null` if unknown. + private @Nullable String cachedSkinModel; + + /// Returns the cached skin texture URL of this account. + /// + /// @return the cached skin texture URL, or `null` if no skin has been cached for this account + public @Nullable String getCachedSkinUrl() { + return cachedSkinUrl; + } + + /// Returns the cached skin model name of this account. + /// + /// @return the cached skin model name (`"slim"` or `"default"`), or `null` if unknown + public @Nullable String getCachedSkinModel() { + return cachedSkinModel; + } + + /// Updates the cached skin of this account, persisting it into the account private data. + /// + /// This method does nothing if the cache is already up to date. It is safe to call from any thread. + /// + /// @param url the skin texture URL, or `null` to clear the cache + /// @param model the skin model name (`"slim"` or `"default"`), or `null` if unknown + public void setCachedSkin(@Nullable String url, @Nullable String model) { + if (Objects.equals(url, cachedSkinUrl) && Objects.equals(model, cachedSkinModel)) { + return; + } + this.cachedSkinUrl = url; + this.cachedSkinModel = model; + invalidate(); + } + + /// Restores the cached skin of an account from its serialized account private data. + /// + /// The restored value does not invalidate the account, since it is identical to the persisted state. + /// + /// @param account the account whose cached skin should be restored + /// @param storage the serialized account private data + public static void restoreCachedSkin(Account account, JsonObject storage) { + @Nullable String url = JsonUtils.getString(storage, PROPERTY_CACHED_SKIN_URL); + if (url == null) { + return; + } + account.cachedSkinUrl = url; + account.cachedSkinModel = JsonUtils.getString(storage, PROPERTY_CACHED_SKIN_MODEL); + } + /// Writes private account data into the target JSON object. /// /// Private data is stored outside `accounts.json` and may contain credentials or cached profile data. @@ -116,6 +172,12 @@ public void writeMetadata(JsonObject metadata) { /// not retain a reference to it. @MustBeInvokedByOverriders public void writePrivateData(JsonObject privateData) { + if (cachedSkinUrl != null) { + privateData.addProperty(PROPERTY_CACHED_SKIN_URL, cachedSkinUrl); + if (cachedSkinModel != null) { + privateData.addProperty(PROPERTY_CACHED_SKIN_MODEL, cachedSkinModel); + } + } } public void clearCache() {