diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index c94a86accc..5c8fe769c6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -50,6 +50,7 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.ui.account.AccountListPage; +import org.jackhuang.hmcl.ui.account.SkinManagePage; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.Motion; @@ -123,6 +124,7 @@ public final class Controllers { }); private static LauncherSettingsPage settingsPage; private static Lazy terracottaPage = new Lazy<>(TerracottaPage::new); + private static Lazy skinManagePage = new Lazy<>(SkinManagePage::new); private Controllers() { } @@ -203,6 +205,11 @@ public static Node getTerracottaPage() { return terracottaPage.get(); } + // FXThread + public static SkinManagePage getSkinManagePage() { + return skinManagePage.get(); + } + // FXThread public static DecoratorController getDecorator() { return decorator; @@ -630,6 +637,7 @@ public static void shutdown() { accountListPage = null; settingsPage = null; terracottaPage = null; + skinManagePage = null; decorator = null; stage = null; scene = null; 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 65bbabb6a8..0e35256ec4 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 @@ -25,8 +25,6 @@ import javafx.beans.value.ObservableBooleanValue; import javafx.scene.control.RadioButton; import javafx.scene.control.Skin; -import javafx.scene.image.Image; -import javafx.stage.FileChooser; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.CredentialExpiredException; @@ -36,19 +34,10 @@ import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.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.construct.MessageDialogPane.MessageType; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.skin.InvalidSkinException; -import org.jackhuang.hmcl.util.skin.NormalizedSkin; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; + import java.util.Optional; import java.util.Set; import java.util.concurrent.CancellationException; @@ -132,49 +121,9 @@ public ObservableBooleanValue canUploadSkin() { } } - /** - * @return the skin upload task, null if no file is selected - */ - @Nullable - public Task uploadSkin() { - if (account instanceof OfflineAccount) { - Controllers.dialog(new OfflineAccountSkinPane((OfflineAccount) account)); - return null; - } - if (!account.canUploadSkin()) { - return null; - } - - FileChooser chooser = new FileChooser(); - chooser.setTitle(i18n("account.skin.upload")); - chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png")); - Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage())); - if (selectedFile == null) { - return null; - } - - return refreshAsync() - .thenRunAsync(() -> { - Image skinImg; - try (var input = Files.newInputStream(selectedFile)) { - skinImg = new Image(input); - } catch (IOException e) { - throw new InvalidSkinException("Failed to read skin image", e); - } - if (skinImg.isError()) { - throw new InvalidSkinException("Failed to read skin image", skinImg.getException()); - } - NormalizedSkin skin = new NormalizedSkin(skinImg); - String model = skin.isSlim() ? "slim" : ""; - LOG.info("Uploading skin [" + selectedFile + "], model [" + model + "]"); - account.uploadSkin(skin.isSlim(), selectedFile); - }) - .thenComposeAsync(refreshAsync()) - .whenComplete(Schedulers.javafx(), e -> { - if (e != null) { - Controllers.dialog(Accounts.localizeErrorMessage(e), i18n("account.skin.upload.failed"), MessageType.ERROR); - } - }); + public void navigateToSkinPage() { + Controllers.getSkinManagePage().loadAccount(account); + Controllers.navigate(Controllers.getSkinManagePage()); } public void remove() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java index c2551eff5e..aebcaf2f73 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java @@ -37,7 +37,6 @@ import org.jackhuang.hmcl.game.TexturesLoader; 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.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -149,21 +148,10 @@ public AccountListItemSkin(AccountListItem skinnable) { right.getChildren().add(spinnerRefresh); JFXButton btnUpload = FXUtils.newToggleButton4(SVG.CHECKROOM); - SpinnerPane spinnerUpload = new SpinnerPane(); - btnUpload.setOnAction(e -> { - Task uploadTask = skinnable.uploadSkin(); - if (uploadTask != null) { - spinnerUpload.showSpinner(); - uploadTask - .whenComplete(Schedulers.javafx(), ex -> spinnerUpload.hideSpinner()) - .start(); - } - }); + btnUpload.setOnAction(e -> skinnable.navigateToSkinPage()); FXUtils.installFastTooltip(btnUpload, i18n("account.skin.upload")); btnUpload.disableProperty().bind(Bindings.not(skinnable.canUploadSkin())); - spinnerUpload.setContent(btnUpload); - spinnerUpload.getStyleClass().add("small-spinner-pane"); - right.getChildren().add(spinnerUpload); + right.getChildren().add(btnUpload); JFXButton btnCopyUUID = FXUtils.newToggleButton4(SVG.CONTENT_COPY); SpinnerPane spinnerCopyUUID = new SpinnerPane(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java index bdcaff5f0a..174891eccf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java @@ -199,9 +199,24 @@ public AccountListPageSkin(AccountListPage skinnable) { VBox.setMargin(addAuthServerItem, new Insets(0, 0, 12, 0)); } + AdvancedListItem skinManageItem = new AdvancedListItem(); + { + skinManageItem.getStyleClass().add("navigation-drawer-item"); + skinManageItem.setTitle(i18n("account.skin.manage")); + skinManageItem.setLeftIcon(SVG.CHECKROOM); + skinManageItem.setOnAction(e -> { + Account selected = skinnable.selectedAccount.get(); + if (selected != null) { + Controllers.getSkinManagePage().loadAccount(selected); + Controllers.navigate(Controllers.getSkinManagePage()); + } + }); + skinManageItem.disableProperty().bind(Bindings.isNull(skinnable.selectedAccount)); + } + ScrollPane scrollPane = new ScrollPane(boxMethods); VBox.setVgrow(scrollPane, Priority.ALWAYS); - setLeft(scrollPane, addAuthServerItem); + setLeft(scrollPane, skinManageItem, addAuthServerItem); } ScrollPane scrollPane = new ScrollPane(); 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 25467e7e15..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java +++ /dev/null @@ -1,238 +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.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 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()); - } - - skinBinding = FXUtils.observeWeak(() -> { - 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(); - }, 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/SkinManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/SkinManagePage.java new file mode 100644 index 0000000000..de58aeb101 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/SkinManagePage.java @@ -0,0 +1,580 @@ +/* + * 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.JFXTextField; +import javafx.application.Platform; +import javafx.beans.InvalidationListener; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.canvas.Canvas; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Skin; +import javafx.scene.image.Image; +import javafx.scene.input.DragEvent; +import javafx.scene.input.TransferMode; +import javafx.scene.layout.*; +import javafx.stage.FileChooser; +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.auth.AuthenticationException; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; +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.TextureModel; +import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.game.TexturesLoader; +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.FXUtils; +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.io.FileUtils; +import org.jackhuang.hmcl.util.skin.InvalidSkinException; +import org.jackhuang.hmcl.util.skin.NormalizedSkin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.UUID; + +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 SkinManagePage extends DecoratorAnimatedPage implements DecoratorPage, PageAware { + + private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("account.skin.manage"), 200)); + private final ObjectProperty account = new SimpleObjectProperty<>(); + + private SkinCanvas canvas; + private InvalidationListener skinBinding; + + // Offline UI controls + private MultiFileItem skinItem; + private JFXTextField cslApiField; + private JFXComboBox modelCombobox; + private FileSelector skinSelector; + private FileSelector capeSelector; + + public SkinManagePage() { + } + + public void loadAccount(Account account) { + this.account.set(account); + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + return state.getReadOnlyProperty(); + } + + @Override + public void onPageShown() { + if (canvas != null) { + canvas.getAnimationPlayer().start(); + } + } + + @Override + public void onPageHidden() { + if (canvas != null) { + canvas.getAnimationPlayer().stop(); + } + } + + @Override + protected Skin createDefaultSkin() { + return new SkinManagePageSkin(this); + } + + private org.jackhuang.hmcl.auth.offline.Skin getOfflineSkin() { + org.jackhuang.hmcl.auth.offline.Skin.Type type = skinItem.getSelectedData(); + if (type == org.jackhuang.hmcl.auth.offline.Skin.Type.LOCAL_FILE) { + return new org.jackhuang.hmcl.auth.offline.Skin(type, cslApiField.getText(), modelCombobox.getValue(), skinSelector.getValue(), capeSelector.getValue()); + } else { + String cslApi = type == org.jackhuang.hmcl.auth.offline.Skin.Type.CUSTOM_SKIN_LOADER_API ? cslApiField.getText() : null; + return new org.jackhuang.hmcl.auth.offline.Skin(type, cslApi, null, null, null); + } + } + + private static class SkinManagePageSkin extends DecoratorAnimatedPageSkin { + + SkinManagePageSkin(SkinManagePage skinnable) { + super(skinnable); + + skinnable.canvas = new SkinCanvas(TexturesLoader.getDefaultSkinImage(), 400, 400, true); + SkinCanvas canvas = skinnable.canvas; + canvas.getAnimationPlayer().addSkinAnimation( + new SkinAniWavingArms(100, 2000, 7.5, canvas), + new SkinAniRunning(100, 100, 30, canvas) + ); + canvas.enableRotation(.5); + + // Rebuild UI when account changes + skinnable.account.addListener((obs, oldAccount, newAccount) -> { + if (newAccount != null) { + rebuildUI(skinnable, canvas, newAccount); + } + }); + + // Build initial UI if account is already set + Account initialAccount = skinnable.account.get(); + if (initialAccount != null) { + rebuildUI(skinnable, canvas, initialAccount); + } else { + StackPane canvasPane = new StackPane(canvas); + canvasPane.setAlignment(Pos.CENTER); + setCenter(canvasPane); + } + } + + private void rebuildUI(SkinManagePage skinnable, SkinCanvas canvas, Account account) { + if (account instanceof OfflineAccount) { + buildOfflineUI(skinnable, canvas, (OfflineAccount) account); + } else { + buildOnlineUI(skinnable, canvas, account); + } + } + + private void buildOfflineUI(SkinManagePage skinnable, SkinCanvas canvas, OfflineAccount account) { + skinnable.skinItem = new MultiFileItem<>(); + skinnable.cslApiField = new JFXTextField(); + skinnable.modelCombobox = new JFXComboBox<>(); + skinnable.skinSelector = new FileSelector(); + skinnable.capeSelector = new FileSelector(); + + MultiFileItem skinItem = skinnable.skinItem; + JFXTextField cslApiField = skinnable.cslApiField; + JFXComboBox modelCombobox = skinnable.modelCombobox; + FileSelector skinSelector = skinnable.skinSelector; + FileSelector capeSelector = skinnable.capeSelector; + + // Drag and drop on canvas + 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(org.jackhuang.hmcl.auth.offline.Skin.Type.LOCAL_FILE); + }); + } + }); + + // CSL API field + cslApiField.setPromptText(i18n("account.skin.type.csl_api.location.hint")); + cslApiField.setValidators(new URLValidator()); + FXUtils.setValidateWhileTextChanged(cslApiField, true); + + // Skin type options + skinItem.loadChildren(Arrays.asList( + new MultiFileItem.Option<>(i18n("message.default"), org.jackhuang.hmcl.auth.offline.Skin.Type.DEFAULT), + new MultiFileItem.Option<>(i18n("account.skin.type.steve"), org.jackhuang.hmcl.auth.offline.Skin.Type.STEVE), + new MultiFileItem.Option<>(i18n("account.skin.type.alex"), org.jackhuang.hmcl.auth.offline.Skin.Type.ALEX), + new MultiFileItem.Option<>(i18n("account.skin.type.local_file"), org.jackhuang.hmcl.auth.offline.Skin.Type.LOCAL_FILE), + new MultiFileItem.Option<>(i18n("account.skin.type.little_skin"), org.jackhuang.hmcl.auth.offline.Skin.Type.LITTLE_SKIN), + new MultiFileItem.Option<>(i18n("account.skin.type.csl_api"), org.jackhuang.hmcl.auth.offline.Skin.Type.CUSTOM_SKIN_LOADER_API) + )); + + // Model combobox + modelCombobox.setConverter(stringConverter(model -> i18n("account.skin.model." + model.modelName))); + modelCombobox.getItems().setAll(TextureModel.WIDE, TextureModel.SLIM); + + // Load current skin settings + if (account.getSkin() == null) { + skinItem.setSelectedData(org.jackhuang.hmcl.auth.offline.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()); + } + + // Reactive skin preview + skinnable.skinBinding = FXUtils.observeWeak(() -> { + skinnable.getOfflineSkin().load(account.getUsername()) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception != null) { + LOG.warning("Failed to load skin", exception); + } else { + UUID uuid = 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(); + }, skinItem.selectedDataProperty(), cslApiField.textProperty(), modelCombobox.valueProperty(), skinSelector.valueProperty(), capeSelector.valueProperty()); + + // Dynamic sub-options pane + StackPane skinOptionPane = new StackPane(); + skinOptionPane.setMaxWidth(300); + + // Hint pane shown above canvas when LITTLE_SKIN is selected + HintPane littleSkinHint = new HintPane(MessageDialogPane.MessageType.INFO); + littleSkinHint.setText(i18n("account.skin.type.little_skin.hint")); + littleSkinHint.setMaxWidth(Double.MAX_VALUE); + littleSkinHint.setMaxHeight(Region.USE_PREF_SIZE); + littleSkinHint.setMinHeight(Region.USE_PREF_SIZE); + littleSkinHint.setVisible(false); + littleSkinHint.setManaged(false); + + FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> { + GridPane gridPane = new GridPane(); + gridPane.setPadding(new Insets(0, 0, 10, 10)); + gridPane.setHgap(16); + gridPane.setVgap(8); + gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); + + boolean showHint = selectedData == org.jackhuang.hmcl.auth.offline.Skin.Type.LITTLE_SKIN; + littleSkinHint.setVisible(showHint); + littleSkinHint.setManaged(showHint); + + switch (selectedData) { + case DEFAULT: + case STEVE: + case ALEX: + case LITTLE_SKIN: + 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); + }); + + // === Left sidebar === + VBox leftBox = new VBox(); + leftBox.getStyleClass().add("advanced-list-box-content"); + FXUtils.setLimitWidth(leftBox, 200); + + // Account header + Canvas avatar = new Canvas(32, 32); + TexturesLoader.bindAvatar(avatar, account); + Label nameLabel = new Label(account.getCharacter()); + nameLabel.getStyleClass().add("title"); + Label typeLabel = new Label(Accounts.getLocalizedLoginTypeName(Accounts.getAccountFactory(account))); + typeLabel.getStyleClass().add("subtitle"); + VBox accountInfo = new VBox(nameLabel, typeLabel); + accountInfo.getStyleClass().add("two-line-list-item"); + HBox header = new HBox(8, avatar, accountInfo); + header.setAlignment(Pos.CENTER_LEFT); + header.setPadding(new Insets(10)); + + ClassTitle skinTitle = new ClassTitle(i18n("account.skin.source")); + + // Save / Cancel buttons + JFXButton saveButton = new JFXButton(i18n("account.skin.save")); + saveButton.getStyleClass().add("jfx-button-raised"); + saveButton.setStyle("-fx-background-color: #5C6BC0; -fx-text-fill: white;"); + saveButton.setOnAction(e -> { + account.setSkin(skinnable.getOfflineSkin()); + Controllers.showToast(i18n("account.skin.save.success")); + Controllers.navigate(Controllers.getAccountListPage()); + }); + saveButton.disableProperty().bind( + skinItem.selectedDataProperty().isEqualTo(org.jackhuang.hmcl.auth.offline.Skin.Type.CUSTOM_SKIN_LOADER_API) + .and(cslApiField.activeValidatorProperty().isNotNull())); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage())); + + HBox buttons = new HBox(10, saveButton, cancelButton); + buttons.setAlignment(Pos.CENTER); + buttons.setPadding(new Insets(10)); + + JFXHyperlink littleSkinLink = new JFXHyperlink(i18n("account.skin.type.little_skin")); + littleSkinLink.setOnAction(e -> FXUtils.openLink("https://littleskin.cn/")); + + ScrollPane leftScrollPane = new ScrollPane(new VBox(header, skinTitle, skinItem)); + leftScrollPane.setFitToWidth(true); + VBox.setVgrow(leftScrollPane, Priority.ALWAYS); + + leftBox.getChildren().setAll(leftScrollPane, littleSkinLink, buttons); + + setLeft(leftBox); + + // === Center content === + StackPane canvasPane = new StackPane(canvas); + canvasPane.setAlignment(Pos.CENTER); + + VBox centerBox = new VBox(10); + centerBox.setAlignment(Pos.CENTER); + centerBox.setPadding(new Insets(20)); + centerBox.getChildren().setAll(littleSkinHint, canvasPane, skinOptionPane); + + ScrollPane centerScroll = new ScrollPane(centerBox); + centerScroll.setFitToWidth(true); + FXUtils.smoothScrolling(centerScroll); + + setCenter(centerScroll); + } + + private void buildOnlineUI(SkinManagePage skinnable, SkinCanvas canvas, Account account) { + // Detect whether this account supports skin upload + boolean canUpload; + String serverHomepage = null; + String serverName = null; + if (account instanceof AuthlibInjectorAccount aiAccount) { + AuthlibInjectorServer server = aiAccount.getServer(); + serverName = server.getName(); + serverHomepage = server.getLinks().getOrDefault("homepage", server.getUrl()); + java.util.Optional profile = aiAccount.getYggdrasilService() + .getProfileRepository().binding(aiAccount.getUUID()).get(); + canUpload = profile + .map(AuthlibInjectorAccount::getUploadableTextures) + .orElse(java.util.Collections.emptySet()) + .contains(TextureType.SKIN); + } else { + canUpload = account.canUploadSkin(); + } + + // Bind current server skin to canvas + TexturesLoader.skinBinding(account).addListener((obs, oldVal, newVal) -> { + if (newVal != null) { + boolean isSlim = "slim".equals(newVal.getMetadata().get("model")); + canvas.updateSkin(newVal.getImage(), isSlim, null); + } + }); + // Initial load + TexturesLoader.LoadedTexture initial = TexturesLoader.getDefaultSkin(account.getUUID()); + boolean isSlim = TexturesLoader.getDefaultModel(account.getUUID()) == TextureModel.SLIM; + canvas.updateSkin(initial.getImage(), isSlim, null); + + SpinnerPane spinnerPane = new SpinnerPane(); + + if (canUpload) { + // Drag and drop for online upload + 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 file = e.getDragboard().getFiles().get(0).toPath(); + Platform.runLater(() -> doUploadSkin(canvas, account, file, spinnerPane)); + } + }); + } + + // === Left sidebar === + VBox leftBox = new VBox(); + leftBox.getStyleClass().add("advanced-list-box-content"); + FXUtils.setLimitWidth(leftBox, 200); + + Canvas avatar = new Canvas(32, 32); + TexturesLoader.bindAvatar(avatar, account); + Label nameLabel = new Label(account.getCharacter()); + nameLabel.getStyleClass().add("title"); + Label typeLabel = new Label(Accounts.getLocalizedLoginTypeName(Accounts.getAccountFactory(account))); + typeLabel.getStyleClass().add("subtitle"); + VBox accountInfo = new VBox(nameLabel, typeLabel); + accountInfo.getStyleClass().add("two-line-list-item"); + HBox header = new HBox(8, avatar, accountInfo); + header.setAlignment(Pos.CENTER_LEFT); + header.setPadding(new Insets(10)); + + Region spacer = new Region(); + VBox.setVgrow(spacer, Priority.ALWAYS); + + if (canUpload) { + JFXButton uploadButton = new JFXButton(i18n("account.skin.upload")); + uploadButton.getStyleClass().add("jfx-button-raised"); + uploadButton.setStyle("-fx-background-color: #5C6BC0; -fx-text-fill: white;"); + uploadButton.setMaxWidth(Double.MAX_VALUE); + uploadButton.setOnAction(e -> { + FileChooser chooser = new FileChooser(); + chooser.setTitle(i18n("account.skin.upload")); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png")); + Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage())); + if (selectedFile != null) { + doUploadSkin(canvas, account, selectedFile, spinnerPane); + } + }); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.setMaxWidth(Double.MAX_VALUE); + cancelButton.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage())); + + leftBox.getChildren().setAll(header, spacer, uploadButton, cancelButton); + } else { + // No upload support — guide user to the server's website + if (serverHomepage != null) { + String homepage = serverHomepage; + JFXHyperlink serverLink = new JFXHyperlink(serverName != null ? serverName : homepage); + serverLink.setOnAction(e -> FXUtils.openLink(homepage)); + + HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO); + hint.setText(i18n("account.skin.upload.server_unsupported")); + + leftBox.getChildren().setAll(header, hint, serverLink, spacer); + } else { + leftBox.getChildren().setAll(header, spacer); + } + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.setMaxWidth(Double.MAX_VALUE); + cancelButton.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage())); + leftBox.getChildren().add(cancelButton); + } + + leftBox.setPadding(new Insets(0, 0, 10, 0)); + leftBox.setSpacing(8); + + setLeft(leftBox); + + // === Center content === + StackPane canvasPane = new StackPane(canvas); + canvasPane.setAlignment(Pos.CENTER); + spinnerPane.setContent(canvasPane); + + setCenter(spinnerPane); + } + + private void doUploadSkin(SkinCanvas canvas, Account account, Path file, SpinnerPane spinnerPane) { + spinnerPane.showSpinner(); + + Task.runAsync(() -> { + account.clearCache(); + try { + account.logIn(); + } catch (Exception ignored) { + } + }).thenRunAsync(() -> { + Image skinImg; + try (var input = Files.newInputStream(file)) { + skinImg = new Image(input); + } catch (IOException e) { + throw new InvalidSkinException("Failed to read skin image", e); + } + if (skinImg.isError()) { + throw new InvalidSkinException("Failed to read skin image", skinImg.getException()); + } + NormalizedSkin skin = new NormalizedSkin(skinImg); + LOG.info("Uploading skin [" + file + "], model [" + (skin.isSlim() ? "slim" : "classic") + "]"); + account.uploadSkin(skin.isSlim(), file); + }).thenRunAsync(() -> { + account.clearCache(); + try { + account.logIn(); + } catch (Exception ignored) { + } + }).whenComplete(Schedulers.javafx(), e -> { + spinnerPane.hideSpinner(); + if (e != null) { + Controllers.dialog(localizeSkinUploadError(e), i18n("account.skin.upload.failed"), MessageDialogPane.MessageType.ERROR); + } else { + Controllers.showToast(i18n("account.skin.upload.success")); + // Refresh skin preview + TexturesLoader.skinBinding(account).addListener((obs, oldVal, newVal) -> { + if (newVal != null) { + boolean isSlim = "slim".equals(newVal.getMetadata().get("model")); + canvas.updateSkin(newVal.getImage(), isSlim, null); + } + }); + } + }).start(); + } + + private static String localizeSkinUploadError(Exception e) { + if (e instanceof InvalidSkinException) { + return i18n("account.skin.invalid_skin"); + } + + Throwable cause = e; + // Unwrap AuthenticationException to find the root cause + if (e instanceof AuthenticationException && e.getCause() != null) { + cause = e.getCause(); + } + + if (cause instanceof java.net.ConnectException || cause instanceof java.net.UnknownHostException) { + return i18n("account.skin.upload.failed.network"); + } + if (cause instanceof java.net.SocketTimeoutException) { + return i18n("account.skin.upload.failed.timeout"); + } + if (cause instanceof org.jackhuang.hmcl.util.io.ResponseCodeException) { + int code = ((org.jackhuang.hmcl.util.io.ResponseCodeException) cause).getResponseCode(); + if (code == 401 || code == 403) { + return i18n("account.skin.upload.failed.auth_expired"); + } + return i18n("account.skin.upload.failed.server_error", code); + } + + // AuthenticationException with raw message from API + if (e instanceof AuthenticationException) { + String msg = e.getMessage(); + if (msg != null && msg.contains("response code:")) { + // Extract response code from "Failed to upload skin, response code: 403, response: ..." + try { + String codeStr = msg.replaceAll(".*response code:\\s*(\\d+).*", "$1"); + int code = Integer.parseInt(codeStr); + if (code == 401 || code == 403) { + return i18n("account.skin.upload.failed.auth_expired"); + } + return i18n("account.skin.upload.failed.server_error", code); + } catch (NumberFormatException ignored) { + } + } + } + + return Accounts.localizeErrorMessage(e); + } + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 605e328b8c..1ae583db30 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -165,6 +165,16 @@ account.skin.type.steve=Steve account.skin.upload=Upload/Edit Skin account.skin.upload.failed=Failed to upload skin. account.skin.invalid_skin=Invalid skin file. +account.skin.manage=Skin Management +account.skin.source=Skin Source +account.skin.save=Save +account.skin.save.success=Skin saved successfully. +account.skin.upload.success=Skin uploaded successfully. +account.skin.upload.server_unsupported=This server does not support uploading skins through the launcher. Please visit the server's website to manage your skin. +account.skin.upload.failed.network=Unable to connect to the server. Please check your network connection and try again. +account.skin.upload.failed.timeout=Connection timed out. Please check your network and try again. +account.skin.upload.failed.auth_expired=Authentication expired or insufficient permissions. Please refresh your account and try again. +account.skin.upload.failed.server_error=The server returned an error (HTTP %1$d). Please try again later. account.username=Username archive.author=Author(s) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 0f7d9c6633..ca314215c4 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -162,6 +162,16 @@ account.skin.type.steve=Steve account.skin.upload=上傳/編輯外觀 account.skin.upload.failed=外觀上傳失敗 account.skin.invalid_skin=無法識別的外觀檔案 +account.skin.manage=外觀管理 +account.skin.source=外觀來源 +account.skin.save=儲存 +account.skin.save.success=外觀儲存成功 +account.skin.upload.success=外觀上傳成功 +account.skin.upload.server_unsupported=該伺服器不支援透過啟動器上傳外觀,請前往伺服器網站管理你的外觀。 +account.skin.upload.failed.network=無法連線至伺服器,請檢查網路連線後重試。 +account.skin.upload.failed.timeout=連線逾時,請檢查網路後重試。 +account.skin.upload.failed.auth_expired=登入憑證已過期或權限不足,請重新整理帳戶後重試。 +account.skin.upload.failed.server_error=伺服器回傳錯誤(HTTP %1$d),請稍後重試。 account.username=使用者名稱 archive.author=作者 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 9e408399c3..f17b38d3c0 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -164,6 +164,16 @@ account.skin.type.steve=Steve account.skin.upload=上传/编辑皮肤 account.skin.upload.failed=皮肤上传失败 account.skin.invalid_skin=无法识别的皮肤文件 +account.skin.manage=皮肤管理 +account.skin.source=皮肤来源 +account.skin.save=保存 +account.skin.save.success=皮肤保存成功 +account.skin.upload.success=皮肤上传成功 +account.skin.upload.server_unsupported=该服务器不支持通过启动器上传皮肤,请前往服务器网站管理你的皮肤。 +account.skin.upload.failed.network=无法连接到服务器,请检查网络连接后重试。 +account.skin.upload.failed.timeout=连接超时,请检查网络后重试。 +account.skin.upload.failed.auth_expired=登录凭证已过期或权限不足,请刷新账户后重试。 +account.skin.upload.failed.server_error=服务器返回错误(HTTP %1$d),请稍后重试。 account.username=用户名 archive.author=作者