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 ab0c15dd72..6d8f4c2bab 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java @@ -31,8 +31,12 @@ import org.jackhuang.hmcl.auth.ServerResponseMalformedException; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.offline.OfflineAccount; -import org.jackhuang.hmcl.auth.offline.Skin; -import org.jackhuang.hmcl.auth.yggdrasil.*; +import org.jackhuang.hmcl.auth.offline.OfflineSkinConfig; +import org.jackhuang.hmcl.auth.yggdrasil.Texture; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.game.skin.TextureModel; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.FXUtils; @@ -45,7 +49,10 @@ import java.lang.ref.WeakReference; import java.nio.file.Files; import java.nio.file.Path; -import java.util.*; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -65,29 +72,18 @@ private TexturesLoader() { } // ==== Texture Loading ==== - public static class LoadedTexture { - private final Image image; - private final Map metadata; - + public record LoadedTexture(Image image, Map metadata) { public LoadedTexture(Image image, Map metadata) { this.image = requireNonNull(image); this.metadata = requireNonNull(metadata); } - - public Image getImage() { - return image; - } - - public Map getMetadata() { - return metadata; - } } private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS); private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins"); private static Path getTexturePath(Texture texture) { - String url = texture.getUrl(); + String url = texture.url(); int slash = url.lastIndexOf('/'); int dot = url.lastIndexOf('.'); if (dot < slash) { @@ -99,7 +95,7 @@ private static Path getTexturePath(Texture texture) { } public static LoadedTexture loadTexture(Texture texture) throws Throwable { - if (StringUtils.isBlank(texture.getUrl())) { + if (StringUtils.isBlank(texture.url())) { throw new IOException("Texture url is empty"); } @@ -107,14 +103,14 @@ public static LoadedTexture loadTexture(Texture texture) throws Throwable { if (!Files.isRegularFile(file)) { // download it try { - new FileDownloadTask(texture.getUrl(), file).run(); - LOG.info("Texture downloaded: " + texture.getUrl()); + new FileDownloadTask(texture.url(), file).run(); + LOG.info("Texture downloaded: " + texture.url()); } catch (Exception e) { if (Files.isRegularFile(file)) { // concurrency conflict? - LOG.warning("Failed to download texture " + texture.getUrl() + ", but the file is available", e); + LOG.warning("Failed to download texture " + texture.url() + ", but the file is available", e); } else { - throw new IOException("Failed to download texture " + texture.getUrl()); + throw new IOException("Failed to download texture " + texture.url()); } } } @@ -127,7 +123,7 @@ public static LoadedTexture loadTexture(Texture texture) throws Throwable { if (img.isError()) throw img.getException(); - Map metadata = texture.getMetadata(); + Map metadata = texture.metadata(); if (metadata == null) { metadata = emptyMap(); } @@ -158,7 +154,7 @@ public static LoadedTexture getDefaultSkin(UUID uuid) { } public static TextureModel getDefaultModel(UUID uuid) { - return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).getMetadata().get("model")) + return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).metadata().get("model")) ? TextureModel.WIDE : TextureModel.SLIM; } @@ -176,7 +172,7 @@ public static ObjectBinding skinBinding(YggdrasilService service, } }) .flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN))) - .filter(it -> StringUtils.isNotBlank(it.getUrl()))) + .filter(it -> StringUtils.isNotBlank(it.url()))) .asyncMap(it -> { if (it.isPresent()) { Texture texture = it.get(); @@ -184,7 +180,7 @@ public static ObjectBinding skinBinding(YggdrasilService service, try { return loadTexture(texture); } catch (Throwable e) { - LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e); + LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e); return uuidFallback; } }, POOL); @@ -196,28 +192,26 @@ public static ObjectBinding skinBinding(YggdrasilService service, public static ObservableValue skinBinding(Account account) { LoadedTexture uuidFallback = getDefaultSkin(account.getUUID()); - if (account instanceof OfflineAccount) { - OfflineAccount offlineAccount = (OfflineAccount) account; + if (account instanceof OfflineAccount offlineAccount) { SimpleObjectProperty binding = new SimpleObjectProperty<>(); InvalidationListener listener = o -> { - Skin skin = offlineAccount.getSkin(); - String username = offlineAccount.getUsername(); + OfflineSkinConfig skin = offlineAccount.getSkin(); binding.set(uuidFallback); if (skin != null) { - skin.load(username).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> { + skin.load().setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception != null) { LOG.warning("Failed to load texture", exception); - } else if (result != null && result.getSkin() != null && result.getSkin().getImage() != null) { + } else if (result != null && result.skin() != null && result.skin().image() != null) { Map metadata; - if (result.getModel() != null) { - metadata = singletonMap("model", result.getModel().modelName); + if (result.model() != null) { + metadata = singletonMap("model", result.model().modelName); } else { metadata = emptyMap(); } - binding.set(new LoadedTexture(result.getSkin().getImage(), metadata)); + binding.set(new LoadedTexture(result.skin().image(), metadata)); } }).start(); } @@ -234,12 +228,12 @@ public static ObservableValue skinBinding(Account account) { .asyncMap(textures -> { if (textures.isPresent()) { Texture texture = textures.get().get(TextureType.SKIN); - if (texture != null && StringUtils.isNotBlank(texture.getUrl())) { + if (texture != null && StringUtils.isNotBlank(texture.url())) { return CompletableFuture.supplyAsync(() -> { try { return loadTexture(texture); } catch (Throwable e) { - LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e); + LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e); return uuidFallback; } }, POOL); @@ -275,15 +269,12 @@ private static void drawAvatar(GraphicsContext g, Image skin, int size, int scal 0, 0, size, size); } - private static final class SkinBindingChangeListener implements ChangeListener { + private record SkinBindingChangeListener(WeakReference canvasRef, + ObservableValue binding) implements ChangeListener { static final WeakHashMap hole = new WeakHashMap<>(); - final WeakReference canvasRef; - final ObservableValue binding; - - SkinBindingChangeListener(Canvas canvas, ObservableValue binding) { - this.canvasRef = new WeakReference<>(canvas); - this.binding = binding; + private SkinBindingChangeListener(Canvas canvasRef, ObservableValue binding) { + this(new WeakReference<>(canvasRef), binding); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index 13a67943c1..d704425fd2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -38,6 +38,7 @@ public enum SVG { ARROW_DROP_DOWN("M12 15 7 10H17L12 15Z"), ARROW_DROP_UP("M7 14 12 9 17 14H7Z"), ARROW_FORWARD("M16.175 13H4V11H16.175L10.575 5.4 12 4 20 12 12 20 10.575 18.6 16.175 13Z"), + APPAREL("m6 10.95l-1.875 1.025l-2.975-5.2L7.75 3H10v1q0 .825.588 1.413T12 6t1.413-.587T14 4V3h2.25l6.6 3.775l-2.95 5.15l-1.9-.95V21H6zM8 7.6V19h8V7.6l3.1 1.7l1.05-1.75l-4.3-2.5q-.375 1.275-1.412 2.113T12 8t-2.437-.837T8.15 5.05l-4.3 2.5L4.9 9.3zm4 4.425"), BETA_CIRCLE("M15,10.5C15,11.3 14.3,12 13.5,12C14.3,12 15,12.7 15,13.5V15A2,2 0 0,1 13,17H9V7H13A2,2 0 0,1 15,9V10.5M13,15V13H11V15H13M13,11V9H11V11H13M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material CANCEL("M8.4 17 12 13.4 15.6 17 17 15.6 13.4 12 17 8.4 15.6 7 12 10.6 8.4 7 7 8.4 10.6 12 7 15.6 8.4 17ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), CHAT("M6 14H14V12H6V14ZM6 11H18V9H6V11ZM6 8H18V6H6V8ZM2 22V4Q2 3.175 2.5875 2.5875T4 2H20Q20.825 2 21.4125 2.5875T22 4V16Q22 16.825 21.4125 17.4125T20 18H6L2 22ZM5.15 16H20V4H4V17.125L5.15 16ZM4 16V4 16Z"), @@ -49,6 +50,7 @@ public enum SVG { CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"), CONTENT_PASTE("M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5h-2v3H7V5H5zm7-14q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"), CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"), + CROP_9_16("M9 21q-.825 0-1.412-.587T7 19V5q0-.825.588-1.412T9 3h6q.825 0 1.413.588T17 5v14q0 .825-.587 1.413T15 21zM9 5v14h6V5zm0 0v14z"), DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"), DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"), DEPLOYED_CODE("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM12 10.85 17.925 7.425 12 4 6.075 7.425 12 10.85ZM4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725L4 17.7ZM12 12Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java index fa5de7480a..0306255930 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java @@ -34,12 +34,13 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.DialogController; +import org.jackhuang.hmcl.ui.account.skin.OfflineAccountSkinPage; import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.skin.InvalidSkinException; @@ -55,8 +56,8 @@ import static java.util.Collections.emptySet; import static javafx.beans.binding.Bindings.createBooleanBinding; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class AccountListItem extends RadioButton { @@ -138,7 +139,7 @@ public ObservableBooleanValue canUploadSkin() { @Nullable public Task uploadSkin() { if (account instanceof OfflineAccount) { - Controllers.dialog(new OfflineAccountSkinPane((OfflineAccount) account)); + Controllers.navigate(new OfflineAccountSkinPage((OfflineAccount) account)); return null; } if (!account.canUploadSkin()) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java deleted file mode 100644 index 88d53558a0..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.account; - -import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXComboBox; -import com.jfoenix.controls.JFXDialogLayout; -import com.jfoenix.controls.JFXTextField; -import javafx.animation.PauseTransition; -import javafx.application.Platform; -import javafx.beans.InvalidationListener; -import javafx.geometry.Insets; -import javafx.geometry.VPos; -import javafx.scene.control.Label; -import javafx.scene.input.DragEvent; -import javafx.scene.input.TransferMode; -import javafx.scene.layout.*; -import javafx.util.Duration; -import org.jackhuang.hmcl.auth.offline.OfflineAccount; -import org.jackhuang.hmcl.auth.offline.Skin; -import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; -import org.jackhuang.hmcl.game.TexturesLoader; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.ui.skin.SkinCanvas; -import org.jackhuang.hmcl.ui.skin.animation.SkinAniRunning; -import org.jackhuang.hmcl.ui.skin.animation.SkinAniWavingArms; -import org.jackhuang.hmcl.util.io.FileUtils; - -import java.nio.file.Path; -import java.util.Arrays; -import java.util.UUID; - -import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; -import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public class OfflineAccountSkinPane extends StackPane { - private final OfflineAccount account; - - private final MultiFileItem skinItem = new MultiFileItem<>(); - private final JFXTextField cslApiField = new JFXTextField(); - private final JFXComboBox modelCombobox = new JFXComboBox<>(); - private final FileSelector skinSelector = new FileSelector(); - private final FileSelector capeSelector = new FileSelector(); - - private final InvalidationListener skinBinding; - - public OfflineAccountSkinPane(OfflineAccount account) { - this.account = account; - - getStyleClass().add("skin-pane"); - - JFXDialogLayout layout = new JFXDialogLayout(); - getChildren().setAll(layout); - layout.setHeading(new Label(i18n("account.skin"))); - - BorderPane pane = new BorderPane(); - - SkinCanvas canvas = new SkinCanvas(TexturesLoader.getDefaultSkinImage(), 300, 300, true); - StackPane canvasPane = new StackPane(canvas); - canvasPane.setPrefWidth(300); - canvasPane.setPrefHeight(300); - pane.setCenter(canvas); - canvas.getAnimationPlayer().addSkinAnimation(new SkinAniWavingArms(100, 2000, 7.5, canvas), new SkinAniRunning(100, 100, 30, canvas)); - canvas.enableRotation(.5); - - canvas.addEventHandler(DragEvent.DRAG_OVER, e -> { - if (e.getDragboard().hasFiles()) { - Path file = e.getDragboard().getFiles().get(0).toPath(); - if (FileUtils.getName(file).endsWith(".png")) - e.acceptTransferModes(TransferMode.COPY); - } - }); - canvas.addEventHandler(DragEvent.DRAG_DROPPED, e -> { - if (e.isAccepted()) { - Path skin = e.getDragboard().getFiles().get(0).toPath(); - Platform.runLater(() -> { - skinSelector.setValue(FileUtils.getAbsolutePath(skin)); - skinItem.setSelectedData(Skin.Type.LOCAL_FILE); - }); - } - }); - - StackPane skinOptionPane = new StackPane(); - skinOptionPane.setMaxWidth(300); - VBox optionPane = new VBox(skinItem, skinOptionPane); - pane.setRight(optionPane); - - skinSelector.maxWidthProperty().bind(skinOptionPane.maxWidthProperty().multiply(0.7)); - capeSelector.maxWidthProperty().bind(skinOptionPane.maxWidthProperty().multiply(0.7)); - - layout.setBody(pane); - - cslApiField.setPromptText(i18n("account.skin.type.csl_api.location.hint")); - cslApiField.setValidators(new URLValidator()); - FXUtils.setValidateWhileTextChanged(cslApiField, true); - - skinItem.loadChildren(Arrays.asList( - new MultiFileItem.Option<>(i18n("message.default"), Skin.Type.DEFAULT), - new MultiFileItem.Option<>(i18n("account.skin.type.steve"), Skin.Type.STEVE), - new MultiFileItem.Option<>(i18n("account.skin.type.alex"), Skin.Type.ALEX), - new MultiFileItem.Option<>(i18n("account.skin.type.local_file"), Skin.Type.LOCAL_FILE), - new MultiFileItem.Option<>(i18n("account.skin.type.little_skin"), Skin.Type.LITTLE_SKIN), - new MultiFileItem.Option<>(i18n("account.skin.type.csl_api"), Skin.Type.CUSTOM_SKIN_LOADER_API) - )); - - modelCombobox.setConverter(stringConverter(model -> i18n("account.skin.model." + model.modelName))); - modelCombobox.getItems().setAll(TextureModel.WIDE, TextureModel.SLIM); - - if (account.getSkin() == null) { - skinItem.setSelectedData(Skin.Type.DEFAULT); - modelCombobox.setValue(TextureModel.WIDE); - } else { - skinItem.setSelectedData(account.getSkin().getType()); - cslApiField.setText(account.getSkin().getCslApi()); - modelCombobox.setValue(account.getSkin().getTextureModel()); - skinSelector.setValue(account.getSkin().getLocalSkinPath()); - capeSelector.setValue(account.getSkin().getLocalCapePath()); - } - - PauseTransition pauseTransition = new PauseTransition(Duration.seconds(1)); - - Runnable loadSkin = () -> { - getSkin().load(account.getUsername()) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - if (exception != null) { - LOG.warning("Failed to load skin", exception); - Controllers.showToast(i18n("message.failed")); - } else { - UUID uuid = this.account.getUUID(); - if (result == null || result.getSkin() == null && result.getCape() == null) { - canvas.updateSkin( - TexturesLoader.getDefaultSkin(uuid).getImage(), - TexturesLoader.getDefaultModel(uuid) == TextureModel.SLIM, - null - ); - return; - } - canvas.updateSkin( - result.getSkin() != null ? result.getSkin().getImage() : TexturesLoader.getDefaultSkin(uuid).getImage(), - result.getModel() == TextureModel.SLIM, - result.getCape() != null ? result.getCape().getImage() : null); - } - }).start(); - }; - - pauseTransition.setOnFinished(e -> loadSkin.run()); - - skinBinding = FXUtils.observeWeak(() -> { - Skin.Type selectedType = skinItem.getSelectedData(); - - if (selectedType == Skin.Type.CUSTOM_SKIN_LOADER_API) { - if (!cslApiField.validate()) { - pauseTransition.stop(); - return; - } - pauseTransition.playFromStart(); - } else { - pauseTransition.stop(); - loadSkin.run(); - } - }, skinItem.selectedDataProperty(), cslApiField.textProperty(), modelCombobox.valueProperty(), skinSelector.valueProperty(), capeSelector.valueProperty()); - - FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> { - GridPane gridPane = new GridPane(); - // Increase bottom padding to prevent the prompt from overlapping with the dialog action area - - gridPane.setPadding(new Insets(0, 0, 45, 10)); - gridPane.setHgap(16); - gridPane.setVgap(8); - gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); - - switch (selectedData) { - case DEFAULT: - case STEVE: - case ALEX: - break; - case LITTLE_SKIN: - HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO); - hint.setText(i18n("account.skin.type.little_skin.hint")); - - // Spanning two columns and expanding horizontally - GridPane.setColumnSpan(hint, 2); - GridPane.setHgrow(hint, Priority.ALWAYS); - hint.setMaxWidth(Double.MAX_VALUE); - - // Force top alignment within cells (to avoid vertical offset caused by the baseline) - GridPane.setValignment(hint, VPos.TOP); - - // Set a fixed height as the preferred height to prevent the GridPane from stretching or leaving empty space. - hint.setMaxHeight(Region.USE_PREF_SIZE); - hint.setMinHeight(Region.USE_PREF_SIZE); - - gridPane.addRow(0, hint); - break; - case LOCAL_FILE: - gridPane.setPadding(new Insets(0, 0, 0, 10)); - gridPane.addRow(0, new Label(i18n("account.skin.model")), modelCombobox); - gridPane.addRow(1, new Label(i18n("account.skin")), skinSelector); - gridPane.addRow(2, new Label(i18n("account.cape")), capeSelector); - break; - case CUSTOM_SKIN_LOADER_API: - gridPane.addRow(0, new Label(i18n("account.skin.type.csl_api.location")), cslApiField); - break; - } - - skinOptionPane.getChildren().setAll(gridPane); - }); - - JFXButton acceptButton = new JFXButton(i18n("button.ok")); - acceptButton.getStyleClass().add("dialog-accept"); - acceptButton.setOnAction(e -> { - account.setSkin(getSkin()); - fireEvent(new DialogCloseEvent()); - }); - - JFXHyperlink littleSkinLink = new JFXHyperlink(i18n("account.skin.type.little_skin")); - littleSkinLink.setOnAction(e -> FXUtils.openLink("https://littleskin.cn/")); - JFXButton cancelButton = new JFXButton(i18n("button.cancel")); - cancelButton.getStyleClass().add("dialog-cancel"); - cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); - onEscPressed(this, cancelButton::fire); - - acceptButton.disableProperty().bind( - skinItem.selectedDataProperty().isEqualTo(Skin.Type.CUSTOM_SKIN_LOADER_API) - .and(cslApiField.activeValidatorProperty().isNotNull())); - - layout.setActions(littleSkinLink, acceptButton, cancelButton); - } - - private Skin getSkin() { - Skin.Type type = skinItem.getSelectedData(); - if (type == Skin.Type.LOCAL_FILE) { - return new Skin(type, cslApiField.getText(), modelCombobox.getValue(), skinSelector.getValue(), capeSelector.getValue()); - } else { - String cslApi = type == Skin.Type.CUSTOM_SKIN_LOADER_API ? cslApiField.getText() : null; - return new Skin(type, cslApi, null, null, null); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/OfflineAccountSkinPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/OfflineAccountSkinPage.java new file mode 100644 index 0000000000..6f07dd13bf --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/OfflineAccountSkinPage.java @@ -0,0 +1,175 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.account.skin; + +import com.jfoenix.controls.JFXComboBox; +import javafx.beans.InvalidationListener; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.auth.offline.OfflineSkinConfig; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.game.skin.Skin; +import org.jackhuang.hmcl.game.skin.TextureModel; +import org.jackhuang.hmcl.game.skin.TextureObject; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.construct.FileSelector; +import org.jackhuang.hmcl.ui.construct.MultiFileItem; +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.UUID; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public class OfflineAccountSkinPage extends SkinPageBase { + private ReadOnlyObjectWrapper skinProperty; + + private final MultiFileItem skinTypeItem = new MultiFileItem<>(); + private final JFXComboBox modelCombobox = new JFXComboBox<>(); + private final FileSelector skinSelector = new FileSelector(); + private final FileSelector capeSelector = new FileSelector(); + + public OfflineAccountSkinPage(OfflineAccount account) { + super(account, null); + + skinTypeItem.loadChildren(Arrays.asList( + new MultiFileItem.Option<>(i18n("message.default"), OfflineSkinConfig.Type.DEFAULT), + new MultiFileItem.Option<>(i18n("account.skin.type.steve"), OfflineSkinConfig.Type.STEVE), + new MultiFileItem.Option<>(i18n("account.skin.type.alex"), OfflineSkinConfig.Type.ALEX), + new MultiFileItem.Option<>(i18n("account.skin.type.local_file"), OfflineSkinConfig.Type.LOCAL_FILE) + )); + + modelCombobox.setConverter(FXUtils.stringConverter(model -> i18n("account.skin.model." + model.modelName))); + modelCombobox.getItems().setAll(TextureModel.WIDE, TextureModel.SLIM); + + OfflineSkinConfig config = account.getSkin(); + if (config == null) { + skinTypeItem.setSelectedData(OfflineSkinConfig.Type.DEFAULT); + modelCombobox.setValue(TextureModel.WIDE); + } else { + skinTypeItem.setSelectedData(config.type()); + modelCombobox.setValue(config.textureModel() != null ? config.textureModel() : TextureModel.WIDE); + skinSelector.setValue(config.localSkinPath()); + capeSelector.setValue(config.localCapePath()); + } + + StackPane contentPane = super.skinManage.leftRegion; + + VBox settingsBox = new VBox(20); + GridPane grid = new GridPane(); + grid.setAlignment(Pos.CENTER); + grid.setHgap(16); + grid.setVgap(10); + + ChangeListener listener = (obs, oldVal, newVal) -> { + grid.getChildren().clear(); + if (newVal == OfflineSkinConfig.Type.LOCAL_FILE) { + grid.addRow(0, new Label(i18n("account.skin.model")), modelCombobox); + grid.addRow(1, new Label(i18n("account.skin")), skinSelector); + grid.addRow(2, new Label(i18n("account.cape")), capeSelector); + } + }; + + listener.changed(null, null, skinTypeItem.getSelectedData()); + skinTypeItem.selectedDataProperty().addListener(listener); + + settingsBox.getChildren().addAll(skinTypeItem, grid); + contentPane.getChildren().setAll(settingsBox); + StackPane.setAlignment(settingsBox, Pos.CENTER); + settingsBox.setAlignment(Pos.CENTER); + + InvalidationListener invalidationListener = (e) -> { + account.setSkin(getConfig()); + loadSkinPreview(); + }; + + skinTypeItem.selectedDataProperty().addListener(invalidationListener); + modelCombobox.valueProperty().addListener(invalidationListener); + skinSelector.valueProperty().addListener(invalidationListener); + capeSelector.valueProperty().addListener(invalidationListener); + + loadSkinPreview(); + } + + private OfflineSkinConfig getConfig() { + OfflineSkinConfig.Type type = skinTypeItem.getSelectedData(); + if (type == null) type = OfflineSkinConfig.Type.DEFAULT; + TextureModel model = modelCombobox.getValue(); + + var textureModel = switch (type) { + case ALEX -> TextureModel.SLIM; + case STEVE -> TextureModel.WIDE; + case DEFAULT -> TexturesLoader.getDefaultModel(account.getUUID()); + default -> model; + }; + + return new OfflineSkinConfig(type, textureModel, skinSelector.getValue(), capeSelector.getValue()); + } + + private void loadSkinPreview() { + OfflineSkinConfig config = getConfig(); + config.load().whenComplete(Schedulers.javafx(), (loadedSkin, throwable) -> { + if (throwable != null) { + LOG.warning("Failed to load skin for preview", throwable); + Controllers.showToast(i18n("message.failed")); + return; + } + + UUID uuid = account.getUUID(); + TextureModel model = TextureModel.WIDE; + TextureObject skinTex = null; + TextureObject capeTex = null; + + if (loadedSkin != null) { + model = loadedSkin.model(); + skinTex = loadedSkin.skin() != null ? new TextureObject(loadedSkin.skin().image(), "") : null; + capeTex = loadedSkin.cape() != null ? new TextureObject(loadedSkin.cape().image(), "") : null; + } + + if (skinTex == null) { + skinTex = new TextureObject(TexturesLoader.getDefaultSkin(uuid).image(), ""); + model = TexturesLoader.getDefaultModel(uuid); + } + + skinProperty.set(new Skin(model, skinTex, capeTex)); + }).start(); + } + + @Override + protected void onDrag(Path skin) { + skinTypeItem.setSelectedData(OfflineSkinConfig.Type.LOCAL_FILE); + skinSelector.setValue(FileUtils.getAbsolutePath(skin)); + } + + @Override + protected ReadOnlyObjectProperty skinObjectProperty() { + if (skinProperty == null) skinProperty = new ReadOnlyObjectWrapper<>(); + return skinProperty.getReadOnlyProperty(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/SkinPageBase.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/SkinPageBase.java new file mode 100644 index 0000000000..a992d91f65 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/SkinPageBase.java @@ -0,0 +1,197 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.account.skin; + +import com.jfoenix.controls.JFXPopup; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.geometry.Insets; +import javafx.scene.input.TransferMode; +import javafx.scene.layout.*; +import javafx.stage.FileChooser; +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.game.skin.Skin; +import org.jackhuang.hmcl.game.skin.TextureModel; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.ui.skin.SkinCanvas; +import org.jackhuang.hmcl.ui.skin.animation.SkinAniRunning; +import org.jackhuang.hmcl.ui.skin.animation.SkinAniWavingArms; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.SwingFXUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Nullable; + +import javax.imageio.ImageIO; +import java.awt.image.RenderedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public abstract class SkinPageBase extends DecoratorAnimatedPage implements DecoratorPage, PageAware { + protected final T account; + @Nullable + private final String url; + private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); + private final BooleanProperty loadingProperty = new SimpleBooleanProperty(true); + private final TabHeader tab; + private final TabHeader.Tab manageTab = new TabHeader.Tab<>("manageTab"); + private final TransitionPane transitionPane = new TransitionPane(); + + protected final SkinManage skinManage; + + protected SkinPageBase(T account, @Nullable String url) { + this.url = url; + this.account = account; + + tab = new TabHeader(transitionPane, manageTab); + skinManage = new SkinManage(); + manageTab.setNodeSupplier(() -> skinManage); + tab.select(manageTab); + + BorderPane left = new BorderPane(); + FXUtils.setLimitWidth(left, 200); + VBox.setVgrow(left, Priority.ALWAYS); + setLeft(left); + + AdvancedListBox sideBar = new AdvancedListBox().addNavigationDrawerTab(tab, manageTab, i18n("account.skin"), SVG.CHECKROOM); + left.setTop(sideBar); + + PopupMenu saveList = new PopupMenu(); + JFXPopup savePopup = new JFXPopup(saveList); + + var capeItem = new IconedMenuItem(SVG.CROP_9_16, i18n("account.skin.manage.save.cape"), () -> { + var fxCapeImage = skinObjectProperty().get().cape().image(); + var bufferedCapeImage = SwingFXUtils.fromFXImage(fxCapeImage, null); + try { + savePng(bufferedCapeImage, "cape"); + } catch (Exception e) { + LOG.warning("Failed to export skin img", e); + Controllers.dialog(i18n("message.failed") + "\n" + StringUtils.getStackTrace(e), i18n("message.failed"), MessageDialogPane.MessageType.ERROR); + } + }, savePopup); + + saveList.getContent().setAll(new IconedMenuItem(SVG.APPAREL, i18n("account.skin.manage.save.skin"), () -> { + var fxSkinImage = skinObjectProperty().get().skin().image(); + var bufferedSkinImage = SwingFXUtils.fromFXImage(fxSkinImage, null); + try { + savePng(bufferedSkinImage, "skin"); + } catch (Exception e) { + LOG.warning("Failed to export skin img", e); + Controllers.dialog(i18n("message.failed") + "\n" + StringUtils.getStackTrace(e), i18n("message.failed"), MessageDialogPane.MessageType.ERROR); + } + }, savePopup), capeItem); + + skinObjectProperty().addListener((observable, oldValue, newValue) -> { + capeItem.setDisable(newValue.cape() == null); + }); + AdvancedListBox toolbar = new AdvancedListBox().addNavigationDrawerItem(i18n("button.save"), SVG.OUTPUT, null, item -> { + item.setOnAction(e -> savePopup.show(item, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, item.getWidth(), 0)); + }); + BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); + left.setBottom(toolbar); + + skinManage.setOnDragOver(e -> { + if (e.getDragboard().hasFiles()) { + Path file = e.getDragboard().getFiles().get(0).toPath(); + if (FileUtils.getName(file).endsWith(".png")) { + e.acceptTransferModes(TransferMode.COPY); + } + } + }); + skinManage.setOnDragDropped(e -> { + if (e.isAccepted()) { + Path skin = e.getDragboard().getFiles().get(0).toPath(); + Platform.runLater(() -> { + onDrag(skin); + }); + } + }); + + setCenter(transitionPane); + + this.state.set(State.fromTitle(i18n("account.skin.manage", account.getUsername()))); + } + + protected abstract void onDrag(Path skin); + + public void savePng(RenderedImage image, String name) throws IOException { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle(i18n("button.save_as")); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("file"), "*.png")); + fileChooser.setInitialFileName(name + ".png"); + File target = fileChooser.showSaveDialog(Controllers.getStage()); + if (target == null) return; + ImageIO.write(image, "png", target); + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + return state.getReadOnlyProperty(); + } + + protected abstract ReadOnlyObjectProperty skinObjectProperty(); + + protected final class SkinManage extends HBox { + protected StackPane leftRegion = new StackPane(); + private final BorderPane rightRegion = new BorderPane(); + + private SkinManage() { + setSpacing(10); + setPadding(new Insets(10, 10, 10, 10)); + + leftRegion.getStyleClass().add("card-non-transparent"); + HBox.setHgrow(leftRegion, Priority.ALWAYS); + + rightRegion.getStyleClass().add("card-non-transparent"); + FXUtils.setLimitWidth(rightRegion, 250); + + + var uuid = account.getUUID(); + var skin = TexturesLoader.getDefaultSkin(uuid).image(); + var slim = TexturesLoader.getDefaultModel(uuid) == TextureModel.SLIM; + + SkinCanvas canvas = new SkinCanvas(skin, 250, 400, true); + canvas.getScale().setX(1.25); + canvas.getScale().setY(1.25); + canvas.updateSkin(skin, slim, null); + skinObjectProperty().addListener((obs, oldSkin, newSkin) -> { + canvas.updateSkin(newSkin.skin().image(), newSkin.model().isSlim(), newSkin.cape() != null ? newSkin.cape().image() : null); + }); + StackPane canvasPane = new StackPane(canvas); + canvasPane.setPrefWidth(300); + rightRegion.setCenter(canvasPane); + canvas.getAnimationPlayer().addSkinAnimation(new SkinAniWavingArms(100, 2000, 7.5, canvas), new SkinAniRunning(100, 100, 30, canvas)); + canvas.enableRotation(.5); + + getChildren().setAll(leftRegion, rightRegion); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java index e45aa02af1..cb05752118 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java @@ -23,7 +23,7 @@ public class IconedItem extends RipplerContainer { - private Label label; + private final Label label; public IconedItem(Node icon, String text) { this(icon); @@ -34,6 +34,16 @@ public IconedItem(Node icon) { super(createHBox(icon)); label = ((Label) lookup("#label")); getStyleClass().setAll("iconed-item"); + + this.disabledProperty().addListener((observable, oldValue, newValue) -> { + if (newValue) { + this.setOpacity(0.4); + this.setMouseTransparent(true); // 确保不可点击 + } else { + this.setOpacity(1.0); + this.setMouseTransparent(false); + } + }); } private static HBox createHBox(Node icon) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/skin/SkinCanvas.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/skin/SkinCanvas.java index 5bf3cc4c1c..ddd0c39422 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/skin/SkinCanvas.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/skin/SkinCanvas.java @@ -113,6 +113,10 @@ public Image getSkin() { return skin; } + public Scale getScale() { + return scale; + } + public void updateSkin(Image skin, boolean isSlim, final @Nullable Image cape) { if (SkinHelper.isNoRequest(skin) && SkinHelper.isSkin(skin)) { this.srcSkin = skin; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java new file mode 100644 index 0000000000..17f569e5b4 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java @@ -0,0 +1,210 @@ +// Copy from javafx.swing +/* + * Copyright (c) 2012, 2018, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package org.jackhuang.hmcl.util; + +import javafx.scene.image.Image; +import javafx.scene.image.PixelFormat; +import javafx.scene.image.PixelReader; +import javafx.scene.image.WritablePixelFormat; +import javafx.scene.paint.Color; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.awt.image.SampleModel; +import java.awt.image.SinglePixelPackedSampleModel; +import java.nio.IntBuffer; + +/** + * This class provides utility methods for converting data types between + * Swing/AWT and JavaFX formats. + * + * @since JavaFX 2.2 + */ +public final class SwingFXUtils { + private SwingFXUtils() { + } // no instances + + /** + * Determine the optimal BufferedImage type to use for the specified + * {@code fxFormat} allowing for the specified {@code bimg} to be used + * as a potential default storage space if it is not null and is compatible. + * + * @param fxFormat the PixelFormat of the source FX Image + * @param bimg an optional existing {@code BufferedImage} to be used + * for storage if it is compatible, or null + * @return + */ + static int getBestBufferedImageType(PixelFormat fxFormat, BufferedImage bimg, boolean isOpaque) { + if (bimg != null) { + int bimgType = bimg.getType(); + if (bimgType == BufferedImage.TYPE_INT_ARGB || bimgType == BufferedImage.TYPE_INT_ARGB_PRE || (isOpaque && (bimgType == BufferedImage.TYPE_INT_BGR || bimgType == BufferedImage.TYPE_INT_RGB))) { + // We will allow the caller to give us a BufferedImage + // that has an alpha channel, but we might not otherwise + // construct one ourselves. + // We will also allow them to choose their own premultiply + // type which may not match the image. + // If left to our own devices we might choose a more specific + // format as indicated by the choices below. + return bimgType; + } + } + switch (fxFormat.getType()) { + case BYTE_BGRA: + case INT_ARGB: + return BufferedImage.TYPE_INT_ARGB; + case BYTE_RGB: + return BufferedImage.TYPE_INT_RGB; + case BYTE_INDEXED: + return (fxFormat.isPremultiplied() ? BufferedImage.TYPE_INT_ARGB_PRE : BufferedImage.TYPE_INT_ARGB); + case BYTE_BGRA_PRE: + case INT_ARGB_PRE: + default: + return BufferedImage.TYPE_INT_ARGB_PRE; + } + } + + /** + * Determine the appropriate {@link WritablePixelFormat} type that can + * be used to transfer data into the indicated BufferedImage. + * + * @param bimg the BufferedImage that will be used as a destination for + * a {@code PixelReader#getPixels()} operation. + * @return + */ + private static WritablePixelFormat getAssociatedPixelFormat(BufferedImage bimg) { + switch (bimg.getType()) { + // We lie here for xRGB, but we vetted that the src data was opaque + // so we can ignore the alpha. We use ArgbPre instead of Argb + // just to get a loop that does not have divides in it if the + // PixelReader happens to not know the data is opaque. + case BufferedImage.TYPE_INT_RGB: + case BufferedImage.TYPE_INT_ARGB_PRE: + return PixelFormat.getIntArgbPreInstance(); + case BufferedImage.TYPE_INT_ARGB: + return PixelFormat.getIntArgbInstance(); + default: + // Should not happen... + throw new InternalError("Failed to validate BufferedImage type"); + } + } + + private static boolean checkFXImageOpaque(PixelReader pr, int iw, int ih) { + for (int x = 0; x < iw; x++) { + for (int y = 0; y < ih; y++) { + Color color = pr.getColor(x, y); + if (color.getOpacity() != 1.0) { + return false; + } + } + } + return true; + } + + /** + * Snapshots the specified JavaFX {@link Image} object and stores a + * copy of its pixels into a {@link BufferedImage} object, creating + * a new object if needed. + * The method will only convert a JavaFX {@code Image} that is readable + * as per the conditions on the + * {@link Image#getPixelReader() Image.getPixelReader()} + * method. + * If the {@code Image} is not readable, as determined by its + * {@code getPixelReader()} method, then this method will return null. + * If the {@code Image} is a writable, or other dynamic image, then + * the {@code BufferedImage} will only be set to the current state of + * the pixels in the image as determined by its {@link PixelReader}. + * Further changes to the pixels of the {@code Image} will not be + * reflected in the returned {@code BufferedImage}. + *

+ * The optional {@code BufferedImage} parameter may be reused to store + * the copy of the pixels. + * A new {@code BufferedImage} will be created if the supplied object + * is null, is too small or of a type which the image pixels cannot + * be easily converted into. + * + * @param img the JavaFX {@code Image} to be converted + * @param bimg an optional {@code BufferedImage} object that may be + * used to store the returned pixel data + * @return a {@code BufferedImage} containing a snapshot of the JavaFX + * {@code Image}, or null if the {@code Image} is not readable. + * @since JavaFX 2.2 + */ + public static BufferedImage fromFXImage(Image img, BufferedImage bimg) { + PixelReader pr = img.getPixelReader(); + if (pr == null) { + return null; + } + int iw = (int) img.getWidth(); + int ih = (int) img.getHeight(); + PixelFormat fxFormat = pr.getPixelFormat(); + boolean srcPixelsAreOpaque = false; + switch (fxFormat.getType()) { + case INT_ARGB_PRE: + case INT_ARGB: + case BYTE_BGRA_PRE: + case BYTE_BGRA: + // Check fx image opacity only if + // supplied BufferedImage is without alpha channel + if (bimg != null && (bimg.getType() == BufferedImage.TYPE_INT_BGR || bimg.getType() == BufferedImage.TYPE_INT_RGB)) { + srcPixelsAreOpaque = checkFXImageOpaque(pr, iw, ih); + } + break; + case BYTE_RGB: + srcPixelsAreOpaque = true; + break; + } + int prefBimgType = getBestBufferedImageType(pr.getPixelFormat(), bimg, srcPixelsAreOpaque); + if (bimg != null) { + int bw = bimg.getWidth(); + int bh = bimg.getHeight(); + if (bw < iw || bh < ih || bimg.getType() != prefBimgType) { + bimg = null; + } else if (iw < bw || ih < bh) { + Graphics2D g2d = bimg.createGraphics(); + g2d.setComposite(AlphaComposite.Clear); + g2d.fillRect(0, 0, bw, bh); + g2d.dispose(); + } + } + if (bimg == null) { + bimg = new BufferedImage(iw, ih, prefBimgType); + } + DataBufferInt db = (DataBufferInt) bimg.getRaster().getDataBuffer(); + int[] data = db.getData(); + int offset = bimg.getRaster().getDataBuffer().getOffset(); + int scan = 0; + SampleModel sm = bimg.getRaster().getSampleModel(); + if (sm instanceof SinglePixelPackedSampleModel) { + scan = ((SinglePixelPackedSampleModel) sm).getScanlineStride(); + } + + WritablePixelFormat pf = getAssociatedPixelFormat(bimg); + pr.getPixels(0, 0, iw, ih, pf, data, offset, scan); + return bimg; + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 731eaee788..b5ac669eda 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -152,6 +152,9 @@ account.not_logged_in=Not Logged in account.password=Password account.portable=Portable account.skin=Skin +account.skin.manage=Skin Management - %1s +account.skin.manage.save.skin=Save Skin +account.skin.manage.save.cape=Save Cape account.skin.file=Skin File account.skin.model=Model account.skin.model.default=Classic @@ -164,7 +167,7 @@ account.skin.type.little_skin=LittleSkin account.skin.type.little_skin.hint=You need to create a player with the same player name as your offline account on your skin provider site. Your skin will now be set to the skin assigned to your player on the skin provider site. account.skin.type.local_file=Local Skin File account.skin.type.steve=Steve -account.skin.upload=Upload/Edit Skin +account.skin.upload=Manage Skin account.skin.upload.failed=Failed to upload skin. account.skin.invalid_skin=Invalid skin file. account.username=Username diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 90f2157ece..d2a8c10224 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -150,6 +150,9 @@ account.not_logged_in=未登入 account.password=密碼 account.portable=可攜式帳戶 account.skin=外觀 +account.skin.manage=外觀管理 - %1s +account.skin.manage.save.skin=保存外觀 +account.skin.manage.save.cape=保存披风 account.skin.file=外觀圖片檔案 account.skin.model=模型 account.skin.model.default=寬型 @@ -162,7 +165,7 @@ account.skin.type.little_skin=LittleSkin 皮膚站 account.skin.type.little_skin.hint=你需要在皮膚站中新增並使用和該離線帳戶同名角色。此時離線帳戶外觀將為皮膚站上對應角色所設定的外觀。 account.skin.type.local_file=本機外觀圖片檔案 account.skin.type.steve=Steve -account.skin.upload=上傳/編輯外觀 +account.skin.upload=管理外觀 account.skin.upload.failed=外觀上傳失敗 account.skin.invalid_skin=無法識別的外觀檔案 account.username=使用者名稱 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 976a48046c..771f269552 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -152,6 +152,9 @@ account.not_logged_in=未登录 account.password=密码 account.portable=便携账户 account.skin=皮肤 +account.skin.manage=皮肤管理 - %1s +account.skin.manage.save.skin=保存皮肤 +account.skin.manage.save.cape=保存披风 account.skin.file=皮肤图片文件 account.skin.model=模型 account.skin.model.default=宽型 @@ -164,7 +167,7 @@ account.skin.type.little_skin=LittleSkin 皮肤站 account.skin.type.little_skin.hint=你需要在皮肤站中创建并使用和该离线账户同名的角色。此时离线账户皮肤将显示为皮肤站上对应角色所设置的皮肤。\n你可以点击右上角帮助按钮进行求助。 account.skin.type.local_file=本地皮肤图片文件 account.skin.type.steve=Steve -account.skin.upload=上传/编辑皮肤 +account.skin.upload=管理皮肤 account.skin.upload.failed=皮肤上传失败 account.skin.invalid_skin=无法识别的皮肤文件 account.username=用户名 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 3d40c983fe..38753913bc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java @@ -26,7 +26,7 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.javafx.ObservableHelper; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java index 6be37269ab..99e05bce8d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java @@ -19,13 +19,14 @@ import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilSession; import org.jackhuang.hmcl.game.Arguments; import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.function.ExceptionalSupplier; + import java.io.IOException; import java.util.*; import java.util.concurrent.CompletableFuture; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java index aa06138053..878a5ae0d9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java @@ -20,8 +20,8 @@ import javafx.beans.binding.ObjectBinding; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.nio.file.Path; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java index 883d2d0b74..ec59385fb3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java @@ -28,7 +28,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.io.*; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/HashedTexture.java similarity index 84% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/HashedTexture.java index be9690f309..55232f71a0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/HashedTexture.java @@ -30,30 +30,19 @@ import static java.util.Objects.requireNonNull; -public final class Texture { - private final String hash; - private final Image image; - - public Texture(String hash, Image image) { +public record HashedTexture(String hash, Image image) { + public HashedTexture(String hash, Image image) { this.hash = requireNonNull(hash); this.image = requireNonNull(image); } - public String getHash() { - return hash; - } - - public Image getImage() { - return image; - } - - private static final Map textures = new HashMap<>(); + private static final Map textures = new HashMap<>(); public static boolean hasTexture(String hash) { return textures.containsKey(hash); } - public static Texture getTexture(String hash) { + public static HashedTexture getTexture(String hash) { return textures.get(hash); } @@ -100,7 +89,7 @@ private static void putInt(byte[] array, int offset, int x) { array[offset + 3] = (byte) (x >> 0 & 0xff); } - public static Texture loadTexture(InputStream in) throws IOException { + public static HashedTexture loadTexture(InputStream in) throws IOException { if (in == null) return null; Image img; try (InputStream is = in) { @@ -113,17 +102,17 @@ public static Texture loadTexture(InputStream in) throws IOException { return loadTexture(img); } - public static Texture loadTexture(Image image) { + public static HashedTexture loadTexture(Image image) { if (image == null) return null; String hash = computeTextureHash(image); - Texture existent = textures.get(hash); + HashedTexture existent = textures.get(hash); if (existent != null) { return existent; } - Texture texture = new Texture(hash, image); + HashedTexture texture = new HashedTexture(hash, image); existent = textures.putIfAbsent(hash, texture); if (existent != null) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/LoadedOfflineSkin.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/LoadedOfflineSkin.java new file mode 100644 index 0000000000..30fbdf77be --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/LoadedOfflineSkin.java @@ -0,0 +1,23 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.offline; + +import org.jackhuang.hmcl.game.skin.TextureModel; + +public record LoadedOfflineSkin(TextureModel model, HashedTexture skin, HashedTexture cape) { +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java index 7e93fa9019..b8cb924fa5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java @@ -25,9 +25,9 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.game.Arguments; import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; @@ -53,9 +53,9 @@ public class OfflineAccount extends Account { private final AuthlibInjectorArtifactProvider downloader; private final String username; private final UUID uuid; - private Skin skin; + private OfflineSkinConfig skin; - protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, Skin skin) { + protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, OfflineSkinConfig skin) { this.downloader = requireNonNull(downloader); this.username = requireNonNull(username); this.uuid = requireNonNull(uuid); @@ -90,17 +90,17 @@ public String getIdentifier() { return username + ":" + username; } - public Skin getSkin() { + public OfflineSkinConfig getSkin() { return skin; } - public void setSkin(Skin skin) { + public void setSkin(OfflineSkinConfig skin) { this.skin = skin; invalidate(); } - protected boolean loadAuthlibInjector(Skin skin) { - return skin != null && skin.getType() != Skin.Type.DEFAULT; + protected boolean loadAuthlibInjector(OfflineSkinConfig skin) { + return skin != null && skin.type() != OfflineSkinConfig.Type.DEFAULT; } public AuthInfo logInWithoutSkin() throws AuthenticationException { @@ -164,7 +164,7 @@ public Arguments getLaunchArguments(LaunchOptions options) throws IOException { try { server.addCharacter(new YggdrasilServer.Character(uuid, username, - skin != null ? skin.load(username).run() : null)); + skin != null ? skin.load().run() : null)); } catch (IOException e) { // ignore } catch (Exception e) { @@ -220,9 +220,8 @@ public int hashCode() { @Override public boolean equals(Object obj) { - if (!(obj instanceof OfflineAccount)) + if (!(obj instanceof OfflineAccount another)) return false; - OfflineAccount another = (OfflineAccount) obj; return isPortable() == another.isPortable() && username.equals(another.username); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java index e06dbdbb9e..634b4f09f8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java @@ -52,7 +52,7 @@ public OfflineAccount create(String username, UUID uuid) { public OfflineAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) { AdditionalData data; UUID uuid; - Skin skin; + OfflineSkinConfig skin; if (additionalData != null) { data = (AdditionalData) additionalData; uuid = data.uuid == null ? getUUIDFromUserName(username) : data.uuid; @@ -71,7 +71,7 @@ public OfflineAccount fromStorage(Map storage) { UUID uuid = tryCast(storage.get("uuid"), String.class) .map(UUIDTypeAdapter::fromString) .orElse(getUUIDFromUserName(username)); - Skin skin = Skin.fromStorage(tryCast(storage.get("skin"), Map.class).orElse(null)); + OfflineSkinConfig skin = OfflineSkinConfig.fromStorage(tryCast(storage.get("skin"), Map.class).orElse(null)); return new OfflineAccount(downloader, username, uuid, skin); } @@ -82,9 +82,9 @@ public static UUID getUUIDFromUserName(String username) { public static class AdditionalData { private final UUID uuid; - private final Skin skin; + private final OfflineSkinConfig skin; - public AdditionalData(UUID uuid, Skin skin) { + public AdditionalData(UUID uuid, OfflineSkinConfig skin) { this.uuid = uuid; this.skin = skin; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineSkinConfig.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineSkinConfig.java new file mode 100644 index 0000000000..5111586e8e --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineSkinConfig.java @@ -0,0 +1,129 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.offline; + +import javafx.scene.image.Image; +import org.jackhuang.hmcl.game.skin.TextureModel; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import static org.jackhuang.hmcl.util.Lang.mapOf; +import static org.jackhuang.hmcl.util.Lang.tryCast; +import static org.jackhuang.hmcl.util.Pair.pair; + +public record OfflineSkinConfig(Type type, TextureModel textureModel, String localSkinPath, String localCapePath) { + + public enum Type { + DEFAULT, + ALEX, + ARI, + EFE, + KAI, + MAKENA, + NOOR, + STEVE, + SUNNY, + ZURI, + LOCAL_FILE; + + public static Type fromStorage(String type) { + return switch (type) { + case "default" -> DEFAULT; + case "alex" -> ALEX; + case "ari" -> ARI; + case "efe" -> EFE; + case "kai" -> KAI; + case "makena" -> MAKENA; + case "noor" -> NOOR; + case "steve" -> STEVE; + case "sunny" -> SUNNY; + case "zuri" -> ZURI; + case "local_file" -> LOCAL_FILE; + default -> null; + }; + } + } + + @Override + public TextureModel textureModel() { + return textureModel == null ? TextureModel.WIDE : textureModel; + } + + public Task load() { + switch (type) { + case DEFAULT: + return Task.supplyAsync(() -> null); + case ALEX: + case ARI: + case EFE: + case KAI: + case MAKENA: + case NOOR: + case STEVE: + case SUNNY: + case ZURI: + TextureModel model = this.textureModel != null ? this.textureModel : type == Type.ALEX ? TextureModel.SLIM : TextureModel.WIDE; + String resource = (model == TextureModel.SLIM ? "/assets/img/skin/slim/" : "/assets/img/skin/wide/") + type.name().toLowerCase(Locale.ROOT) + ".png"; + + return Task.supplyAsync(() -> new LoadedOfflineSkin( + model, + HashedTexture.loadTexture(new Image(resource)), + null + )); + case LOCAL_FILE: + return Task.supplyAsync(() -> { + HashedTexture skin = null, cape = null; + Optional skinPath = FileUtils.tryGetPath(localSkinPath); + Optional capePath = FileUtils.tryGetPath(localCapePath); + if (skinPath.isPresent()) skin = HashedTexture.loadTexture(Files.newInputStream(skinPath.get())); + if (capePath.isPresent()) cape = HashedTexture.loadTexture(Files.newInputStream(capePath.get())); + return new LoadedOfflineSkin(textureModel(), skin, cape); + }); + default: + throw new UnsupportedOperationException(); + } + } + + public Map toStorage() { + return mapOf( + pair("type", type.name().toLowerCase(Locale.ROOT)), + pair("textureModel", textureModel().modelName), + pair("localSkinPath", localSkinPath), + pair("localCapePath", localCapePath) + ); + } + + public static OfflineSkinConfig fromStorage(Map storage) { + if (storage == null) return null; + + Type type = tryCast(storage.get("type"), String.class).flatMap(t -> Optional.ofNullable(Type.fromStorage(t))) + .orElse(Type.DEFAULT); + String textureModel = tryCast(storage.get("textureModel"), String.class).orElse("default"); + String localSkinPath = tryCast(storage.get("localSkinPath"), String.class).orElse(null); + String localCapePath = tryCast(storage.get("localCapePath"), String.class).orElse(null); + + return new OfflineSkinConfig(type, "slim".equals(textureModel) ? TextureModel.SLIM : TextureModel.WIDE, localSkinPath, localCapePath); + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java deleted file mode 100644 index 9342800364..0000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java +++ /dev/null @@ -1,378 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.auth.offline; - -import com.google.gson.annotations.SerializedName; -import javafx.scene.image.Image; -import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; -import org.jackhuang.hmcl.task.FetchTask; -import org.jackhuang.hmcl.task.GetTask; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.io.NetworkUtils; -import org.jackhuang.hmcl.util.io.UrlResponseInfo; -import org.jetbrains.annotations.Nullable; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; - -import static org.jackhuang.hmcl.util.Lang.mapOf; -import static org.jackhuang.hmcl.util.Lang.tryCast; -import static org.jackhuang.hmcl.util.Pair.pair; - -public class Skin { - - public enum Type { - DEFAULT, - ALEX, - ARI, - EFE, - KAI, - MAKENA, - NOOR, - STEVE, - SUNNY, - ZURI, - LOCAL_FILE, - LITTLE_SKIN, - CUSTOM_SKIN_LOADER_API, - YGGDRASIL_API; - - public static Type fromStorage(String type) { - switch (type) { - case "default": - return DEFAULT; - case "alex": - return ALEX; - case "ari": - return ARI; - case "efe": - return EFE; - case "kai": - return KAI; - case "makena": - return MAKENA; - case "noor": - return NOOR; - case "steve": - return STEVE; - case "sunny": - return SUNNY; - case "zuri": - return ZURI; - case "local_file": - return LOCAL_FILE; - case "little_skin": - return LITTLE_SKIN; - case "custom_skin_loader_api": - return CUSTOM_SKIN_LOADER_API; - case "yggdrasil_api": - return YGGDRASIL_API; - default: - return null; - } - } - } - - private final Type type; - private final String cslApi; - private final TextureModel textureModel; - private final String localSkinPath; - private final String localCapePath; - - public Skin(Type type, String cslApi, TextureModel textureModel, String localSkinPath, String localCapePath) { - this.type = type; - this.cslApi = cslApi; - this.textureModel = textureModel; - this.localSkinPath = localSkinPath; - this.localCapePath = localCapePath; - } - - public Type getType() { - return type; - } - - public String getCslApi() { - return cslApi; - } - - public TextureModel getTextureModel() { - return textureModel == null ? TextureModel.WIDE : textureModel; - } - - public String getLocalSkinPath() { - return localSkinPath; - } - - public String getLocalCapePath() { - return localCapePath; - } - - public Task load(String username) { - switch (type) { - case DEFAULT: - return Task.supplyAsync(() -> null); - case ALEX: - case ARI: - case EFE: - case KAI: - case MAKENA: - case NOOR: - case STEVE: - case SUNNY: - case ZURI: - TextureModel model = this.textureModel != null ? this.textureModel : type == Type.ALEX ? TextureModel.SLIM : TextureModel.WIDE; - String resource = (model == TextureModel.SLIM ? "/assets/img/skin/slim/" : "/assets/img/skin/wide/") + type.name().toLowerCase(Locale.ROOT) + ".png"; - - return Task.supplyAsync(() -> new LoadedSkin( - model, - Texture.loadTexture(new Image(resource)), - null - )); - case LOCAL_FILE: - return Task.supplyAsync(() -> { - Texture skin = null, cape = null; - Optional skinPath = FileUtils.tryGetPath(localSkinPath); - Optional capePath = FileUtils.tryGetPath(localCapePath); - if (skinPath.isPresent()) skin = Texture.loadTexture(Files.newInputStream(skinPath.get())); - if (capePath.isPresent()) cape = Texture.loadTexture(Files.newInputStream(capePath.get())); - return new LoadedSkin(getTextureModel(), skin, cape); - }); - case LITTLE_SKIN: - case CUSTOM_SKIN_LOADER_API: - String realCslApi = type == Type.LITTLE_SKIN - ? "https://littleskin.cn/csl" - : NetworkUtils.addHttpsIfMissing(StringUtils.removeSuffix(Lang.requireNonNullElse(cslApi, ""), "/")); - return Task.composeAsync(() -> new GetTask(String.format("%s/%s.json", realCslApi, username))) - .thenComposeAsync(json -> { - SkinJson result = JsonUtils.GSON.fromJson(json, SkinJson.class); - - if (!result.hasSkin()) { - return Task.supplyAsync(() -> null); - } - - return Task.allOf( - Task.supplyAsync(result::getModel), - result.getHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(String.format("%s/textures/%s", realCslApi, result.getHash())), - result.getCapeHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(String.format("%s/textures/%s", realCslApi, result.getCapeHash())) - ); - }).thenApplyAsync(result -> { - if (result == null) { - return null; - } - - Texture skin, cape; - if (result.get(1) != null) { - skin = Texture.loadTexture((InputStream) result.get(1)); - } else { - skin = null; - } - - if (result.get(2) != null) { - cape = Texture.loadTexture((InputStream) result.get(2)); - } else { - cape = null; - } - - return new LoadedSkin((TextureModel) result.get(0), skin, cape); - }); - default: - throw new UnsupportedOperationException(); - } - } - - public Map toStorage() { - return mapOf( - pair("type", type.name().toLowerCase(Locale.ROOT)), - pair("cslApi", cslApi), - pair("textureModel", getTextureModel().modelName), - pair("localSkinPath", localSkinPath), - pair("localCapePath", localCapePath) - ); - } - - public static Skin fromStorage(Map storage) { - if (storage == null) return null; - - Type type = tryCast(storage.get("type"), String.class).flatMap(t -> Optional.ofNullable(Type.fromStorage(t))) - .orElse(Type.DEFAULT); - String cslApi = tryCast(storage.get("cslApi"), String.class).orElse(null); - String textureModel = tryCast(storage.get("textureModel"), String.class).orElse("default"); - String localSkinPath = tryCast(storage.get("localSkinPath"), String.class).orElse(null); - String localCapePath = tryCast(storage.get("localCapePath"), String.class).orElse(null); - - return new Skin(type, cslApi, "slim".equals(textureModel) ? TextureModel.SLIM : TextureModel.WIDE, localSkinPath, localCapePath); - } - - private static class FetchBytesTask extends FetchTask { - - public FetchBytesTask(String uri) { - super(List.of(NetworkUtils.toURI(uri))); - } - - @Override - protected void useCachedResult(Path cachedFile) throws IOException { - setResult(Files.newInputStream(cachedFile)); - } - - @Override - protected EnumCheckETag shouldCheckETag() { - return EnumCheckETag.CHECK_E_TAG; - } - - @Override - protected Context getContext(@Nullable HttpResponse response, boolean checkETag, String bmclapiHash) throws IOException { - return new Context() { - final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - @Override - public void reset() throws IOException { - baos.reset(); - } - - @Override - public void write(byte[] buffer, int offset, int len) { - baos.write(buffer, offset, len); - } - - @Override - public void close() throws IOException { - if (!isSuccess()) return; - - setResult(new ByteArrayInputStream(baos.toByteArray())); - - if (checkETag) { - repository.cacheBytes(UrlResponseInfo.of(response), baos.toByteArray()); - } - } - }; - } - } - - public static class LoadedSkin { - private final TextureModel model; - private final Texture skin; - private final Texture cape; - - public LoadedSkin(TextureModel model, Texture skin, Texture cape) { - this.model = model; - this.skin = skin; - this.cape = cape; - } - - public TextureModel getModel() { - return model; - } - - public Texture getSkin() { - return skin; - } - - public Texture getCape() { - return cape; - } - } - - private static class SkinJson { - private final String username; - private final String skin; - private final String cape; - private final String elytra; - - @SerializedName(value = "textures", alternate = { "skins" }) - private final TextureJson textures; - - public SkinJson(String username, String skin, String cape, String elytra, TextureJson textures) { - this.username = username; - this.skin = skin; - this.cape = cape; - this.elytra = elytra; - this.textures = textures; - } - - public boolean hasSkin() { - return StringUtils.isNotBlank(username); - } - - @Nullable - public TextureModel getModel() { - if (textures != null && textures.slim != null) { - return TextureModel.SLIM; - } else if (textures != null && textures.defaultSkin != null) { - return TextureModel.WIDE; - } else { - return null; - } - } - - public String getAlexModelHash() { - if (textures != null && textures.slim != null) { - return textures.slim; - } else { - return null; - } - } - - public String getSteveModelHash() { - if (textures != null && textures.defaultSkin != null) { - return textures.defaultSkin; - } else return skin; - } - - public String getHash() { - TextureModel model = getModel(); - if (model == TextureModel.SLIM) - return getAlexModelHash(); - else if (model == TextureModel.WIDE) - return getSteveModelHash(); - else - return null; - } - - public String getCapeHash() { - if (textures != null && textures.cape != null) { - return textures.cape; - } else return cape; - } - - public static class TextureJson { - @SerializedName("default") - private final String defaultSkin; - - private final String slim; - private final String cape; - private final String elytra; - - public TextureJson(String defaultSkin, String slim, String cape, String elytra) { - this.defaultSkin = defaultSkin; - this.slim = slim; - this.cape = cape; - this.elytra = elytra; - } - } - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java index 70a9976d32..3eaf69ff7f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java @@ -19,7 +19,7 @@ import org.glavo.png.javafx.PNGJavaFXUtils; import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; -import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; +import org.jackhuang.hmcl.game.skin.TextureModel; import org.jackhuang.hmcl.util.KeyUtils; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Pair; @@ -127,9 +127,9 @@ private Response profile(Request request) { private Response texture(Request request) { String hash = request.getPathVariables().group("hash"); - if (Texture.hasTexture(hash)) { - Texture texture = Texture.getTexture(hash); - byte[] data = PNGJavaFXUtils.writeImageToArray(texture.getImage()); + if (HashedTexture.hasTexture(hash)) { + HashedTexture texture = HashedTexture.getTexture(hash); + byte[] data = PNGJavaFXUtils.writeImageToArray(texture.image()); Response response = newFixedLengthResponse(Response.Status.OK, "image/png", new ByteArrayInputStream(data), data.length); response.addHeader("Etag", String.format("\"%s\"", hash)); response.addHeader("Cache-Control", "max-age=2592000, public"); @@ -148,48 +148,31 @@ private Optional findCharacterByName(String uuid) { } public void addCharacter(Character character) { - charactersByUuid.put(character.getUUID(), character); - charactersByName.put(character.getName(), character); + charactersByUuid.put(character.uuid(), character); + charactersByName.put(character.name(), character); } - public static class Character { - private final UUID uuid; - private final String name; - private final Skin.LoadedSkin skin; - - public Character(UUID uuid, String name, Skin.LoadedSkin skin) { - this.uuid = uuid; - this.name = name; - this.skin = skin; - } - - public UUID getUUID() { - return uuid; - } - - public String getName() { - return name; - } - + public record Character(UUID uuid, String name, LoadedOfflineSkin skin) { public GameProfile toSimpleResponse() { return new GameProfile(uuid, name); } public Object toCompleteResponse(String rootUrl) { Map realTextures = new HashMap<>(); - if (skin != null && skin.getSkin() != null) { - if (skin.getModel() == TextureModel.SLIM) { + if (skin != null && skin.skin() != null) { + String url = rootUrl + "/textures/" + skin.skin().hash(); + if (skin.model() == TextureModel.SLIM) { realTextures.put("SKIN", mapOf( - pair("url", rootUrl + "/textures/" + skin.getSkin().getHash()), + pair("url", url), pair("metadata", mapOf( pair("model", "slim") )))); } else { - realTextures.put("SKIN", mapOf(pair("url", rootUrl + "/textures/" + skin.getSkin().getHash()))); + realTextures.put("SKIN", mapOf(pair("url", url))); } } - if (skin != null && skin.getCape() != null) { - realTextures.put("CAPE", mapOf(pair("url", rootUrl + "/textures/" + skin.getCape().getHash()))); + if (skin != null && skin.cape() != null) { + realTextures.put("CAPE", mapOf(pair("url", rootUrl + "/textures/" + skin.cape().hash()))); } Map textureResponse = mapOf( diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/Texture.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/Texture.java index 8542b471b3..8f6534ff20 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/Texture.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/Texture.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,27 +23,8 @@ import java.util.Map; @Immutable -public final class Texture { - - private final String url; - private final Map metadata; - +public record Texture(@Nullable String url, @Nullable Map metadata) { public Texture() { this(null, null); } - - public Texture(String url, Map metadata) { - this.url = url; - this.metadata = metadata; - } - - @Nullable - public String getUrl() { - return url; - } - - @Nullable - public Map getMetadata() { - return metadata; - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/User.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/User.java index f3c09d8775..a7da58c039 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/User.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/User.java @@ -18,46 +18,26 @@ package org.jackhuang.hmcl.auth.yggdrasil; import com.google.gson.JsonParseException; - -import java.util.Map; - import com.google.gson.annotations.JsonAdapter; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.Validation; import org.jetbrains.annotations.Nullable; +import java.util.Map; + /** * * @author huang */ @Immutable -public final class User implements Validation { - - private final String id; - - @Nullable - @JsonAdapter(PropertyMapSerializer.class) - private final Map properties; +public record User(String id, + @JsonAdapter(PropertyMapSerializer.class) @Nullable Map properties) implements Validation { public User(String id) { this(id, null); } - public User(String id, @Nullable Map properties) { - this.id = id; - this.properties = properties; - } - - public String getId() { - return id; - } - - @Nullable - public Map getProperties() { - return properties; - } - @Override public void validate() throws JsonParseException { if (StringUtils.isBlank(id)) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java index f253eb40b3..f2b15bc33a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java @@ -19,11 +19,15 @@ import javafx.beans.binding.ObjectBinding; import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.nio.file.Path; -import java.util.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; import static java.util.Objects.requireNonNull; import static org.jackhuang.hmcl.util.logging.Logger.LOG; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java index edbad40a8a..5234c78afe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.ServerDisconnectException; import org.jackhuang.hmcl.auth.ServerResponseMalformedException; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.gson.ValidationTypeAdapterFactory; @@ -45,8 +46,8 @@ import static java.util.Collections.unmodifiableList; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Lang.threadPool; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class YggdrasilService { @@ -211,7 +212,7 @@ private static YggdrasilSession handleAuthenticationResponse(String responseText response.accessToken, response.selectedProfile, response.availableProfiles == null ? null : unmodifiableList(response.availableProfiles), - response.user == null ? null : response.user.getProperties()); + response.user == null ? null : response.user.properties()); } private static void requireEmpty(String response) throws AuthenticationException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/Skin.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/Skin.java new file mode 100644 index 0000000000..0f5e1cf97b --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/Skin.java @@ -0,0 +1,24 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game.skin; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public record Skin(@NotNull TextureModel model, @NotNull TextureObject skin, @Nullable TextureObject cape) { +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureModel.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureModel.java similarity index 81% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureModel.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureModel.java index eb9545a90e..593a946806 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureModel.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureModel.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.jackhuang.hmcl.auth.yggdrasil; +package org.jackhuang.hmcl.game.skin; public enum TextureModel { WIDE("default"), SLIM("slim"); @@ -25,4 +25,8 @@ public enum TextureModel { TextureModel(String modelName) { this.modelName = modelName; } + + public boolean isSlim() { + return modelName.equals("slim"); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureObject.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureObject.java new file mode 100644 index 0000000000..396de8d522 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureObject.java @@ -0,0 +1,30 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game.skin; + +import javafx.scene.image.Image; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; + +public record TextureObject(@NotNull Image image, @NotNull String url) { + public TextureObject of(Path path) { + return new TextureObject(new Image(path.toString()), FileUtils.getAbsolutePath(path)); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureType.java similarity index 85% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureType.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureType.java index db100eeaae..1e0dc5afba 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureType.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.jackhuang.hmcl.auth.yggdrasil; +package org.jackhuang.hmcl.game.skin; public enum TextureType { SKIN, CAPE diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index bce609927d..94e73fbc8d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -490,7 +490,7 @@ public static List listFilesByExtension(Path file, String extension) { } public static Optional tryGetPath(String first, String... more) { - if (first == null) return Optional.empty(); + if (first == null || first.isEmpty()) return Optional.empty(); try { return Optional.of(Paths.get(first, more)); } catch (InvalidPathException e) { diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index 37553e27c7..e4b0c13571 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -131,7 +131,7 @@ - +