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 09500345e23..15c7ad76226 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; @@ -188,17 +192,15 @@ public static ObjectBinding skinBinding(YggdrasilService service, public static ObservableValue skinBinding(Account account) { LoadedTexture uuidFallback = getDefaultSkin(account.getProfileID()); - 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 profileName = offlineAccount.getProfileName(); + OfflineSkinConfig skin = offlineAccount.getSkin(); binding.set(uuidFallback); if (skin != null) { - skin.load(profileName).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.skin() != null && result.skin().image() != null) { @@ -267,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 fb2c3b99858..8b36ec4a098 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 fca58b099e3..e31148a5b98 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 @@ -35,12 +35,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.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; @@ -141,7 +142,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 9b6959f5c2d..00000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java +++ /dev/null @@ -1,271 +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.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(), 260, 260, true); - StackPane canvasPane = new StackPane(canvas); - canvasPane.setPrefWidth(260); - canvasPane.setPrefHeight(260); - pane.setCenter(canvasPane); - 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(); - pane.setRight(skinOptionPane); - - skinSelector.setMaxWidth(Double.MAX_VALUE); - capeSelector.setMaxWidth(Double.MAX_VALUE); - modelCombobox.setMaxWidth(Double.MAX_VALUE); - cslApiField.setMaxWidth(Double.MAX_VALUE); - - 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().type()); - cslApiField.setText(account.getSkin().cslApi()); - modelCombobox.setValue(account.getSkin().textureModel()); - skinSelector.setValue(account.getSkin().localSkinPath()); - capeSelector.setValue(account.getSkin().localCapePath()); - } - - PauseTransition pauseTransition = new PauseTransition(Duration.seconds(1)); - - Runnable loadSkin = () -> { - getSkin().load(account.getProfileName()) - .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.getProfileID(); - if (result == null || result.skin() == null && result.cape() == null) { - canvas.updateSkin( - TexturesLoader.getDefaultSkin(uuid).image(), - TexturesLoader.getDefaultModel(uuid) == TextureModel.SLIM, - null - ); - return; - } - canvas.updateSkin( - result.skin() != null ? result.skin().image() : TexturesLoader.getDefaultSkin(uuid).image(), - result.model() == TextureModel.SLIM, - result.cape() != null ? result.cape().image() : 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()); - - VBox right = new VBox(); - right.setPadding(new Insets(0, 0, 0, 10)); - right.setSpacing(12); - right.setPrefWidth(230); - HBox.setHgrow(right, Priority.ALWAYS); - - HBox body = new HBox(20); - skinItem.setPrefWidth(170); - skinItem.setMinWidth(170); - body.getChildren().add(skinItem); - - skinOptionPane.getChildren().setAll(body); - - FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> { - right.getChildren().clear(); - - if (selectedData != null) { - 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")); - - hint.setMaxWidth(Double.MAX_VALUE); - - right.getChildren().add(hint); - break; - - case LOCAL_FILE: - right.getChildren().addAll( - new Label(i18n("account.skin.model")), - modelCombobox, - new Label(i18n("account.skin")), - skinSelector, - new Label(i18n("account.cape")), - capeSelector - ); - break; - - case CUSTOM_SKIN_LOADER_API: - right.getChildren().addAll( - new Label(i18n("account.skin.type.csl_api.location")), - cslApiField - ); - break; - } - } - - if (right.getChildren().isEmpty()) { - body.getChildren().remove(right); - } else if (!body.getChildren().contains(right)) { - body.getChildren().add(right); - } - }); - - 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 00000000000..a94a1cb402a --- /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.getProfileID()); + 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.getProfileID(); + 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 00000000000..59836b2e213 --- /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.getProfileName()))); + } + + 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.getProfileID(); + 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 e45aa02af15..cb05752118d 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 5bf3cc4c1c8..ddd0c394223 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 00000000000..17f569e5b49 --- /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 199d231bf9c..7239c2e5564 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -155,6 +155,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 @@ -167,7 +170,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 9e571a5b40c..cc7743185c2 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -153,6 +153,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=寬型 @@ -165,7 +168,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 3f339510c78..7775ea117f7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -155,6 +155,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=宽型 @@ -167,7 +170,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 5ff62c427f8..5c937a3903a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java @@ -27,7 +27,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.gson.JsonUtils; 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 403cc60f797..21663564967 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 @@ -20,13 +20,14 @@ import com.google.gson.JsonObject; 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 1d7a1de730b..11da1fb2c88 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 @@ -21,8 +21,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 b65e2587950..9df6a46a84c 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 @@ -30,7 +30,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 87% 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 1a2445c9b5f..55232f71a0b 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,19 +30,19 @@ import static java.util.Objects.requireNonNull; -public record Texture(String hash, 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); } - 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); } @@ -89,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) { @@ -102,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 00000000000..30fbdf77be5 --- /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 1e88d226731..e56f72000da 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 @@ -29,9 +29,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; @@ -54,14 +54,14 @@ public class OfflineAccount extends Account { private final AuthlibInjectorArtifactProvider downloader; private final String profileName; private final UUID profileID; - private Skin skin; + private OfflineSkinConfig skin; protected OfflineAccount( AccountID accountID, AuthlibInjectorArtifactProvider downloader, String profileName, UUID profileID, - Skin skin) { + OfflineSkinConfig skin) { super(accountID); this.downloader = requireNonNull(downloader); this.profileName = requireNonNull(profileName); @@ -87,17 +87,17 @@ public String getProfileName() { return profileName; } - 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.type() != Skin.Type.DEFAULT; + protected boolean loadAuthlibInjector(OfflineSkinConfig skin) { + return skin != null && skin.type() != OfflineSkinConfig.Type.DEFAULT; } public AuthInfo logInWithoutSkin() throws AuthenticationException { @@ -161,7 +161,7 @@ public Arguments getLaunchArguments(LaunchOptions options) throws IOException { try { server.addCharacter(new YggdrasilServer.Character(profileID, profileName, - skin != null ? skin.load(profileName).run() : null)); + skin != null ? skin.load().run() : null)); } catch (IOException e) { // ignore } catch (Exception e) { 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 6c8781364df..b003f8d6142 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 @@ -54,7 +54,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; @@ -77,7 +77,7 @@ public OfflineAccount fromStorage(JsonObject metadata, JsonObject privateData) { UUID profileID = profileIDText != null ? UUIDs.parse(profileIDText) : getUUIDFromUserName(profileName); - Skin skin = Skin.fromStorage(metadata.get("skin") instanceof JsonObject skinObject ? skinObject : null); + OfflineSkinConfig skin = OfflineSkinConfig.fromStorage(metadata.get("skin") instanceof JsonObject skinObject ? skinObject : null); return new OfflineAccount(accountID, downloader, profileName, profileID, skin); } @@ -88,9 +88,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 00000000000..cc224fdfe8e --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineSkinConfig.java @@ -0,0 +1,126 @@ +/* + * 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.JsonObject; +import javafx.scene.image.Image; +import org.jackhuang.hmcl.game.skin.TextureModel; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Optional; + +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 void writeStorage(JsonObject storage) { + storage.addProperty("type", type.name().toLowerCase(Locale.ROOT)); + storage.addProperty("localSkinPath", localSkinPath); + storage.addProperty("localCapePath", localCapePath); + } + + public static OfflineSkinConfig fromStorage(JsonObject storage) { + if (storage == null) return null; + + String typeText = JsonUtils.getString(storage, "type"); + Type type = typeText != null ? Type.fromStorage(typeText) : Type.DEFAULT; + if (type == null) { + type = Type.DEFAULT; + } + String textureModel = JsonUtils.getString(storage, "textureModel", "default"); + String localSkinPath = JsonUtils.getString(storage, "localSkinPath"); + String localCapePath = JsonUtils.getString(storage, "localCapePath"); + + 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 d0d8ebc7d2e..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java +++ /dev/null @@ -1,285 +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.JsonObject; -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.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Locale; -import java.util.Optional; - -public record Skin(Type type, String cslApi, TextureModel textureModel, String localSkinPath, String localCapePath) { - - 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) { - 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; - case "little_skin" -> LITTLE_SKIN; - case "custom_skin_loader_api" -> CUSTOM_SKIN_LOADER_API; - case "yggdrasil_api" -> YGGDRASIL_API; - default -> null; - }; - } - } - - @Override - public TextureModel textureModel() { - return textureModel == null ? TextureModel.WIDE : textureModel; - } - - 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(textureModel(), 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 void writeStorage(JsonObject storage) { - storage.addProperty("type", type.name().toLowerCase(Locale.ROOT)); - storage.addProperty("cslApi", cslApi); - storage.addProperty("textureModel", textureModel().modelName); - storage.addProperty("localSkinPath", localSkinPath); - storage.addProperty("localCapePath", localCapePath); - } - - public static Skin fromStorage(JsonObject storage) { - if (storage == null) return null; - - String typeText = JsonUtils.getString(storage, "type"); - Type type = typeText != null ? Type.fromStorage(typeText) : Type.DEFAULT; - if (type == null) { - type = Type.DEFAULT; - } - String cslApi = JsonUtils.getString(storage, "cslApi"); - String textureModel = JsonUtils.getString(storage, "textureModel", "default"); - String localSkinPath = JsonUtils.getString(storage, "localSkinPath"); - String localCapePath = JsonUtils.getString(storage, "localCapePath"); - - 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 UrlResponseInfo response, boolean checkETag, @Nullable 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(response, baos.toByteArray()); - } - } - }; - } - } - - public record LoadedSkin(TextureModel model, Texture skin, Texture cape) { - } - - private record SkinJson(String username, String skin, String cape, String elytra, - @SerializedName(value = "textures", alternate = {"skins"}) TextureJson 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 record TextureJson(@SerializedName("default") String defaultSkin, String slim, String cape, String 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 2ca68211a52..03157da2361 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 @@ -20,7 +20,7 @@ import org.glavo.uuid.UUIDs; 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,8 +127,8 @@ 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); + 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)); @@ -148,29 +148,11 @@ 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); } @@ -178,6 +160,7 @@ public GameProfile toSimpleResponse() { public Object toCompleteResponse(String rootUrl) { Map realTextures = new HashMap<>(); 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.skin().hash()), 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 d1fde5d3d3f..8f6534ff203 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,21 +23,8 @@ import java.util.Map; @Immutable -public record Texture(String url, Map metadata) { - +public record Texture(@Nullable String url, @Nullable Map metadata) { public Texture() { this(null, null); } - - @Override - @Nullable - public String url() { - return url; - } - - @Override - @Nullable - public Map metadata() { - 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 d9630fd2941..a7da58c0391 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,21 +18,21 @@ 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 record User(String id, @JsonAdapter(PropertyMapSerializer.class) @Nullable Map properties) implements Validation { +public record User(String id, + @JsonAdapter(PropertyMapSerializer.class) @Nullable Map properties) implements Validation { public User(String id) { this(id, null); 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 d178b6951f0..a18a050c605 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 @@ -21,11 +21,14 @@ import javafx.beans.binding.ObjectBinding; import org.glavo.uuid.UUIDs; import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.nio.file.Path; -import java.util.*; +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; @@ -62,10 +65,7 @@ protected YggdrasilAccount(YggdrasilService service, String loginName, String pa GameProfile characterToSelect = selector.select(service, acquiredSession.getAvailableProfiles()); - session = service.refresh( - acquiredSession.getAccessToken(), - acquiredSession.getClientToken(), - characterToSelect); + session = service.refresh(acquiredSession.getAccessToken(), acquiredSession.getClientToken(), characterToSelect); // response validity has been checked in refresh() } else { session = acquiredSession; @@ -78,6 +78,7 @@ protected YggdrasilAccount(YggdrasilService service, String loginName, String pa } private ObjectBinding> profilePropertiesBinding; + private void addProfilePropertiesListener() { // binding() is thread-safe // hold the binding so that it won't be garbage-collected @@ -117,8 +118,7 @@ public synchronized AuthInfo logIn() throws AuthenticationException { throw e; } } - if (acquiredSession.getSelectedProfile() == null || - !acquiredSession.getSelectedProfile().getId().equals(profileID)) { + if (acquiredSession.getSelectedProfile() == null || !acquiredSession.getSelectedProfile().getId().equals(profileID)) { throw new ServerResponseMalformedException("Selected profile changed"); } if (!acquiredSession.hasProfileName()) { @@ -144,15 +144,9 @@ public synchronized AuthInfo logInWithPassword(String password) throws Authentic throw new CharacterDeletedException(); } - GameProfile characterToSelect = acquiredSession.getAvailableProfiles().stream() - .filter(charatcer -> charatcer.getId().equals(profileID)) - .findFirst() - .orElseThrow(CharacterDeletedException::new); + GameProfile characterToSelect = acquiredSession.getAvailableProfiles().stream().filter(charatcer -> charatcer.getId().equals(profileID)).findFirst().orElseThrow(CharacterDeletedException::new); - session = service.refresh( - acquiredSession.getAccessToken(), - acquiredSession.getClientToken(), - characterToSelect); + session = service.refresh(acquiredSession.getAccessToken(), acquiredSession.getClientToken(), characterToSelect); } else { if (!acquiredSession.getSelectedProfile().getId().equals(profileID)) { @@ -186,8 +180,7 @@ public void writeMetadata(JsonObject metadata) { public void writePrivateData(JsonObject privateData) { super.writePrivateData(privateData); session.writePrivateData(privateData); - service.getProfileRepository().getImmediately(profileID).ifPresent(profile -> - privateData.add("profileProperties", JsonUtils.GSON.toJsonTree(profile.getProperties()))); + service.getProfileRepository().getImmediately(profileID).ifPresent(profile -> privateData.add("profileProperties", JsonUtils.GSON.toJsonTree(profile.getProperties()))); } public YggdrasilService getYggdrasilService() { @@ -202,15 +195,14 @@ public void clearCache() { @Override public ObjectBinding>> getTextures() { - return BindingMapping.of(service.getProfileRepository().binding(getProfileID())) - .map(profile -> profile.flatMap(it -> { - try { - return YggdrasilService.getTextures(it); - } catch (ServerResponseMalformedException e) { - LOG.warning("Failed to parse texture payload", e); - return Optional.empty(); - } - })); + return BindingMapping.of(service.getProfileRepository().binding(getProfileID())).map(profile -> profile.flatMap(it -> { + try { + return YggdrasilService.getTextures(it); + } catch (ServerResponseMalformedException e) { + LOG.warning("Failed to parse texture payload", e); + return Optional.empty(); + } + })); } @@ -230,7 +222,6 @@ private static String randomClientToken() { @Override public String toString() { - return "YggdrasilAccount[accountID=" + getAccountID() + ", profileID=" + profileID - + ", loginName=" + loginName + "]"; + return "YggdrasilAccount[accountID=" + getAccountID() + ", profileID=" + profileID + ", loginName=" + loginName + "]"; } } 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 540ba623d70..a2dee3108f1 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 @@ -24,6 +24,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.ValidationTypeAdapterFactory; import org.jackhuang.hmcl.util.io.FileUtils; @@ -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 { 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 00000000000..0f5e1cf97bf --- /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 eb9545a90ed..593a946806e 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 00000000000..396de8d522c --- /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 db100eeaaea..1e0dc5afbaf 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 c583860bcc8..ce70ec429e1 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 @@ -494,7 +494,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 46890c14380..3dfaeb51c01 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -135,7 +135,7 @@ - +