Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -186,6 +187,39 @@ public static ObjectBinding<LoadedTexture> 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<String, String> 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<LoadedTexture> skinBinding(Account account) {
LoadedTexture uuidFallback = getDefaultSkin(account.getProfileID());
if (account instanceof OfflineAccount) {
Expand Down Expand Up @@ -229,16 +263,25 @@ public static ObservableValue<LoadedTexture> skinBinding(Account account) {
if (texture != null && StringUtils.isNotBlank(texture.url())) {
return CompletableFuture.supplyAsync(() -> {
try {
return loadTexture(texture);
LoadedTexture loadedTexture = loadTexture(texture);
@Nullable Map<String, String> 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);
}
}
Expand Down
5 changes: 4 additions & 1 deletion HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
62 changes: 62 additions & 0 deletions HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand All @@ -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() {
Expand Down
Loading