diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index 823b3c0677..68a2e5b9fd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -667,7 +667,7 @@ private static CompletableFuture downloadJava(GameJavaVersion javaV MessageType.QUESTION) .yesOrNo(() -> { DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider(); - Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion) + Controllers.downloadTaskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { future.complete(result); @@ -679,7 +679,8 @@ private static CompletableFuture downloadJava(GameJavaVersion javaV } future.completeExceptionally(new CancellationException()); } - }), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException()))); + }), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException())), + i18n("task.detail.java_download")); }, () -> future.completeExceptionally(new CancellationException())).build()); return future; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java index 0320e13b18..7ea046a387 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java @@ -689,6 +689,21 @@ public void setDisableAutoGameOptions(boolean disableAutoGameOptions) { this.disableAutoGameOptions.set(disableAutoGameOptions); } + @SerializedName("autoBackgroundTask") + private final BooleanProperty autoBackgroundTask = new SimpleBooleanProperty(false); + + public BooleanProperty autoBackgroundTaskProperty() { + return autoBackgroundTask; + } + + public boolean isAutoBackgroundTask() { + return autoBackgroundTask.get(); + } + + public void setAutoBackgroundTask(boolean autoBackgroundTask) { + this.autoBackgroundTask.set(autoBackgroundTask); + } + // Accounts @SerializedName("authlibInjectorServers") 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..e1ce1c6583 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -60,6 +60,7 @@ import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider; import org.jackhuang.hmcl.ui.main.LauncherSettingsPage; import org.jackhuang.hmcl.ui.main.RootPage; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.ui.terracotta.TerracottaPage; import org.jackhuang.hmcl.ui.versions.GameListPage; import org.jackhuang.hmcl.ui.versions.VersionPage; @@ -589,6 +590,23 @@ public static TaskExecutorDialogPane taskDialog(Task task, String title, Task return pane; } + public static TaskExecutorDialogPane downloadTaskDialog(Task task, String title, TaskCancellationAction onCancel, String detail) { + TaskExecutor executor = task.executor(); + return downloadTaskDialog(executor, title, onCancel, detail); + } + + public static TaskExecutorDialogPane downloadTaskDialog(TaskExecutor executor, String title, TaskCancellationAction onCancel, String detail) { + TaskExecutorDialogPane pane = taskDialog(executor, title, onCancel); + + pane.setBackgroundAction(() -> { + TaskCenter.getInstance().enqueue(executor, title, detail); + pane.fireEvent(new DialogCloseEvent()); + }); + + executor.start(); + return pane; + } + public static void navigate(Node node) { decorator.navigate(node, ContainerAnimations.NAVIGATION, Motion.SHORT4, Motion.EASE); } @@ -618,6 +636,12 @@ public static void onHyperlinkAction(String href) { } } + public static boolean isDialogShowing() { + if (decorator == null) return false; + return decorator.getDecorator().getDrawerWrapper() != null + && decorator.getDecorator().getDrawerWrapper().getProperties().get(DialogUtils.PROPERTY_DIALOG_INSTANCE) != null; + } + public static boolean isStopped() { return decorator == null; } 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 c7a99881db..fd19569d51 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -43,6 +43,7 @@ public enum SVG { 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"), CHECK("M9.55 18 3.85 12.3 5.275 10.875 9.55 15.15 18.725 5.975 20.15 7.4 9.55 18Z"), CHECKROOM("M3 20Q2.575 20 2.2875 19.7125T2 19Q2 18.75 2.1 18.5375T2.4 18.2L11 11.75V10Q11 9.575 11.3 9.2875T12.025 9Q12.65 9 13.075 8.55T13.5 7.475Q13.5 6.85 13.0625 6.425T12 6Q11.375 6 10.9375 6.4375T10.5 7.5H8.5Q8.5 6.05 9.525 5.025T12 4Q13.45 4 14.475 5.0125T15.5 7.475Q15.5 8.65 14.8125 9.575T13 10.85V11.75L21.6 18.2Q21.8 18.325 21.9 18.5375T22 19Q22 19.425 21.7125 19.7125T21 20H3ZM6 18H18L12 13.5 6 18Z"), + CHECKLIST("M5.55 19.5 1.85 15.8 3.275 14.35 5.55 16.625 10.15 12.025 11.575 13.475 5.55 19.5ZM5.55 11.5 1.85 7.8 3.275 6.35 5.55 8.625 10.15 4.025 11.575 5.475 5.55 11.5ZM13 17V15H22V17H13ZM13 9V7H22V9H13Z"), CHECK_CIRCLE("M10.6 16.6 17.65 9.55 16.25 8.15 10.6 13.8 7.75 10.95 6.35 12.35 10.6 16.6ZM12 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"), CLOSE("M6.4 19 5 17.6 10.6 12 5 6.4 6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19Z"), CONTENT_CUT("M19 21l-7-7-2.35 2.35q.2.375.275.8T10 18q0 1.65-1.175 2.825T6 22q-1.65 0-2.825-1.175T2 18t1.175-2.825T6 14q.425 0 .85.075t.8.275L10 12 7.65 9.65q-.375.2-.8.275T6 10q-1.65 0-2.825-1.175T2 6q0-1.65 1.175-2.825T6 2q1.65 0 2.825 1.175T10 6q0 .425-.075.85t-.275.8L22 20v1H19Zm-4-10-2-2 6-6h3v1l-7 7ZM7.4125 7.4125Q8 6.825 8 6t-.5875-1.4125Q6.825 4 6 4t-1.4125.5875Q4 5.175 4 6t.5875 1.4125T6 8t1.4125-.5875ZM12.35 12.35q.15-.15.15-.35t-.15-.35-.35-.15-.35.15-.15.35.15.35.35.15.35-.15ZM7.4125 19.4125Q8 18.825 8 18t-.5875-1.4125Q6.825 16 6 16t-1.4125.5875Q4 17.175 4 18t.5875 1.4125Q5.175 20 6 20t1.4125-.5875Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java index b0eb8fc57b..a4b1aac487 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java @@ -21,7 +21,6 @@ import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.ScrollPane; -import javafx.scene.input.MouseEvent; import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; @@ -46,13 +45,6 @@ public class AdvancedListBox extends ScrollPane { setVbarPolicy(ScrollBarPolicy.NEVER); container.getStyleClass().add("advanced-list-box-content"); - - this.addEventFilter(MouseEvent.MOUSE_ENTERED, event -> { - if (container.getHeight() > getHeight()) - setVbarPolicy(ScrollBarPolicy.AS_NEEDED); - }); - this.addEventFilter(MouseEvent.MOUSE_EXITED, - event -> setVbarPolicy(ScrollBarPolicy.NEVER)); } public AdvancedListBox add(Node child) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskExecutorDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskExecutorDialogPane.java index f2a4e9ff7d..28752dca2a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskExecutorDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskExecutorDialogPane.java @@ -21,16 +21,20 @@ import javafx.application.Platform; import javafx.beans.property.StringProperty; import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.task.*; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.ui.SVG; import org.jetbrains.annotations.NotNull; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; @@ -47,6 +51,14 @@ public class TaskExecutorDialogPane extends BorderPane { private final Label lblProgress; private final JFXButton btnCancel; private final TaskListPane taskListPane; + private final JFXButton btnBackground; + private Runnable onBackground; + private Runnable escAction; + private Runnable cancelAction; + + public void setEscAction(Runnable action) { + this.escAction = action; + } public TaskExecutorDialogPane(@NotNull TaskCancellationAction cancel) { this.getStyleClass().add("task-executor-dialog-layout"); @@ -58,13 +70,34 @@ public TaskExecutorDialogPane(@NotNull TaskCancellationAction cancel) { this.setCenter(center); center.setPadding(new Insets(16)); { + HBox titleBar = new HBox(); + titleBar.setAlignment(Pos.CENTER_LEFT); + titleBar.setSpacing(8); + lblTitle = new Label(); lblTitle.setStyle("-fx-font-size: 14px; -fx-font-weight: BOLD;"); + btnBackground = new JFXButton(); + btnBackground.setGraphic(SVG.DOWNLOAD.createIcon(16)); // TODO: 可替换为更合适的后台图标 + btnBackground.getStyleClass().add("toggle-icon4"); + FXUtils.installFastTooltip(btnBackground, i18n("task.move_to_background")); + btnBackground.setOnAction(e -> { + if (onBackground != null) { + onBackground.run(); + } + }); + btnBackground.setVisible(false); + btnBackground.setManaged(false); + + HBox spacer = new HBox(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + titleBar.getChildren().setAll(lblTitle, spacer, btnBackground); + taskListPane = new TaskListPane(); VBox.setVgrow(taskListPane, Priority.ALWAYS); - center.getChildren().setAll(lblTitle, taskListPane); + center.getChildren().setAll(titleBar, taskListPane); } BorderPane bottom = new BorderPane(); @@ -82,6 +115,10 @@ public TaskExecutorDialogPane(@NotNull TaskCancellationAction cancel) { setCancel(cancel); btnCancel.setOnAction(e -> { + if (cancelAction != null) { + cancelAction.run(); + return; + } if (onCancel.getCancellationAction() != null) { if (executor != null) executor.cancel(); @@ -94,7 +131,12 @@ public TaskExecutorDialogPane(@NotNull TaskCancellationAction cancel) { Platform.runLater(() -> lblProgress.setText(message)); }); - onEscPressed(this, btnCancel::fire); + escAction = btnCancel::fire; + onEscPressed(this, () -> { + if (escAction != null) { + escAction.run(); + } + }); } public void setExecutor(TaskExecutor executor) { @@ -134,4 +176,66 @@ public void setCancel(TaskCancellationAction onCancel) { runInFX(() -> btnCancel.setDisable(onCancel == null)); } + + private final AtomicBoolean background = new AtomicBoolean(false); + + public void setBackgroundAction(Runnable action) { + this.onBackground = action; + background.set(false); + + btnBackground.setVisible(action != null); + btnBackground.setManaged(action != null); + + if (action != null) { + btnBackground.setDisable(false); + btnBackground.setOnAction(e -> { + if (!background.compareAndSet(false, true)) { + return; + } + btnBackground.setDisable(true); + onBackground.run(); + }); + } else { + btnBackground.setDisable(false); + btnBackground.setOnAction(null); + } + } + + public void refreshTaskList() { + taskListPane.refresh(); + } + + public void setCancelAction(Runnable action) { + this.cancelAction = action; + } + + public void setCancelText(String text) { + btnCancel.setText(text); + } + + private Label lblWaiting; + + public void setWaitingForBackground(boolean waiting) { + if (waiting) { + if (lblWaiting == null) { + lblWaiting = new Label(i18n("task.waiting_for_background")); + lblWaiting.setStyle("-fx-text-fill: -fx-secondary-text-color; -fx-font-size: 13px;"); + lblWaiting.setWrapText(true); + } + taskListPane.setVisible(false); + taskListPane.setManaged(false); + lblProgress.setVisible(false); + lblProgress.setManaged(false); + ((VBox) getCenter()).getChildren().add(lblWaiting); + } else { + taskListPane.setVisible(true); + taskListPane.setManaged(true); + lblProgress.setVisible(true); + lblProgress.setManaged(true); + lblProgress.setText(""); + if (lblWaiting != null) { + ((VBox) getCenter()).getChildren().remove(lblWaiting); + } + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java index 08c390a95e..9b7e91bae2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java @@ -161,6 +161,9 @@ public void onReady(Task task) { public void onRunning(Task task) { if (!task.getSignificance().shouldShow() || task.getName() == null) return; + if (task.getName() == null) { + task.setName(i18n("task.unnamed")); + } if (task instanceof GameAssetDownloadTask) { task.setName(i18n("assets.download_all")); @@ -522,4 +525,14 @@ public void setThrowable(Throwable throwable) { progress.set(0.); } } + + public void refresh() { + if (executor == null) return; + Platform.runLater(() -> { + stageNodes.clear(); + listView.getItems().clear(); + addStagesHints(executor.getHints()); + updateProgressNodePadding(); + }); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java index baa9786b9e..dfc55f71d9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java @@ -46,14 +46,19 @@ import javafx.util.Duration; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.theme.Themes; +import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.Motion; import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.task.TaskCenter; +import org.jackhuang.hmcl.ui.task.TaskCenterPage; import org.jackhuang.hmcl.ui.wizard.Navigation; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + public class DecoratorSkin extends SkinBase { private final StackPane root, parent; private final StackPane titleContainer; @@ -231,6 +236,35 @@ public DecoratorSkin(Decorator control) { buttonsContainer.setAlignment(Pos.TOP_RIGHT); buttonsContainer.setMaxHeight(40); { + // Background task indicator + JFXButton btnTask = new JFXButton(); + btnTask.setFocusTraversable(false); + btnTask.getStyleClass().add("jfx-decorator-button"); + btnTask.setOnAction(e -> Controllers.navigate(new TaskCenterPage())); + FXUtils.installFastTooltip(btnTask, i18n("task.manage")); + + Label taskBadge = new Label(); + taskBadge.setStyle("-fx-font-size: 10px; -fx-text-fill: white; -fx-background-color: #F44336; " + + "-fx-background-radius: 8; -fx-min-width: 16; -fx-min-height: 16; " + + "-fx-alignment: center; -fx-padding: 0 3 0 3;"); + + StackPane taskIconPane = new StackPane(); + taskIconPane.getChildren().add(SVG.CHECKLIST.createIcon(Themes.titleFillProperty())); + taskIconPane.getChildren().add(taskBadge); + StackPane.setAlignment(taskBadge, Pos.TOP_RIGHT); + StackPane.setMargin(taskBadge, new Insets(-4, -6, 0, 0)); + btnTask.setGraphic(taskIconPane); + + Runnable updateTaskIndicator = () -> { + int count = TaskCenter.getInstance().getEntries().size(); + btnTask.setVisible(count > 0); + btnTask.setManaged(count > 0); + taskBadge.setText(String.valueOf(count)); + }; + updateTaskIndicator.run(); + TaskCenter.getInstance().getEntries().addListener( + (ListChangeListener) change -> updateTaskIndicator.run()); + JFXButton btnHelp = new JFXButton(); btnHelp.setFocusTraversable(false); btnHelp.setGraphic(SVG.HELP.createIcon(Themes.titleFillProperty())); @@ -249,7 +283,7 @@ public DecoratorSkin(Decorator control) { btnClose.getStyleClass().add("jfx-decorator-button"); btnClose.setOnAction(e -> skinnable.close()); - buttonsContainer.getChildren().setAll(btnHelp, btnMin, btnClose); + buttonsContainer.getChildren().setAll(btnTask, btnHelp, btnMin, btnClose); } AnchorPane layer = new AnchorPane(); layer.setPickOnBounds(false); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index 5c7996dea7..20a744f092 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -42,6 +42,7 @@ import org.jackhuang.hmcl.ui.construct.Validator; import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.ui.versions.DownloadListPage; import org.jackhuang.hmcl.ui.versions.HMCLLocalizedDownloadListPage; import org.jackhuang.hmcl.ui.versions.VersionPage; @@ -134,10 +135,18 @@ public static void download(DownloadProvider downloadProvider, Profile profile, Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version) : profile.getRepository().getBaseDirectory(); + String detailKey = switch (subdirectoryName) { + case "mods" -> "task.detail.install_mod"; + case "resourcepacks" -> "task.detail.install_resourcepack"; + case "shaderpacks" -> "task.detail.install_shaderpack"; + case "saves" -> "task.detail.install_world"; + default -> "task.detail.download"; + }; + Controllers.prompt(i18n("archive.file.name"), (result, handler) -> { Path dest = runDirectory.resolve(subdirectoryName).resolve(result); - Controllers.taskDialog(Task.composeAsync(() -> { + Controllers.downloadTaskDialog(Task.composeAsync(() -> { var task = new FileDownloadTask(downloadProvider.injectURLWithCandidates(file.getFile().getUrl()), dest); task.setName(file.getName()); return task; @@ -148,10 +157,12 @@ public static void download(DownloadProvider downloadProvider, Profile profile, } else { Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR); } - } else { + } + else { Controllers.showToast(i18n("install.success")); } - }), i18n("message.downloading"), TaskCancellationAction.NORMAL); + }), i18n("message.downloading"), TaskCancellationAction.NORMAL, + i18n(detailKey, file.getName())); handler.resolve(); }, file.getFile().getFilename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid)); @@ -301,6 +312,12 @@ public Object finish(SettingsMap settings) { settings.put("success_message", i18n("install.success")); settings.put(FailureCallback.KEY, (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next)); + settings.put("task_detail", i18n("task.detail.install_game", (String) settings.get("name"))); + settings.put("backgroundable", true); + settings.put("return_to_download_list", true); + settings.put("task_kind", TaskCenter.TaskKind.GAME_INSTALL); + settings.put("task_name", settings.get("name")); + return finishVersionDownloadingAsync(settings); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java index 3d07ed74b6..714ab5f704 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java @@ -26,6 +26,7 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.RequiredValidator; import org.jackhuang.hmcl.ui.construct.Validator; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.util.SettingsMap; import org.jackhuang.hmcl.util.i18n.I18n; @@ -42,7 +43,8 @@ public InstallersPage(WizardController controller, HMCLGameRepository repository txtName.getValidators().addAll( new RequiredValidator(), - new Validator(i18n("install.new_game.already_exists"), str -> !repository.versionIdConflicts(str)), + new Validator(i18n("install.new_game.already_exists"), str -> + !repository.versionIdConflicts(str) && !TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.GAME_INSTALL, str)), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId)); installable.bind(createBooleanBinding(txtName::validate, txtName.textProperty())); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java index 064f580d52..ac19724e1b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java @@ -35,6 +35,7 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.RequiredValidator; import org.jackhuang.hmcl.ui.construct.Validator; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.util.SettingsMap; import org.jackhuang.hmcl.util.StringUtils; @@ -67,12 +68,13 @@ public LocalModpackPage(WizardController controller) { if (installAsVersion) { txtModpackName.getValidators().setAll( new RequiredValidator(), - new Validator(i18n("install.new_game.already_exists"), str -> !profile.getRepository().versionIdConflicts(str)), + new Validator(i18n("install.new_game.already_exists"), str -> + !profile.getRepository().versionIdConflicts(str) && !TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.MODPACK_INSTALL, str)), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId)); } else { txtModpackName.getValidators().setAll( new RequiredValidator(), - new Validator(i18n("install.new_game.already_exists"), str -> !ModpackHelper.isExternalGameNameConflicts(str) && Profiles.getProfiles().stream().noneMatch(p -> p.getName().equals(str))), + new Validator(i18n("install.new_game.already_exists"), str -> !ModpackHelper.isExternalGameNameConflicts(str) && Profiles.getProfiles().stream().noneMatch(p -> p.getName().equals(str)) && !TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.MODPACK_INSTALL, str)), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId)); } }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java index 1826421b10..e78e53b3e5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java @@ -30,6 +30,7 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.ui.wizard.WizardProvider; import org.jackhuang.hmcl.util.SettingsMap; @@ -144,6 +145,14 @@ public Object finish(SettingsMap settings) { } }); + String taskName = settings.get(LocalModpackPage.MODPACK_NAME); + if (taskName != null) { + settings.put("task_detail", i18n("task.detail.install_modpack", taskName)); + } + settings.put("backgroundable", true); + settings.put("task_kind", TaskCenter.TaskKind.MODPACK_INSTALL); + settings.put("task_name", taskName); + return finishModpackInstallingAsync(settings); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/RemoteModpackPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/RemoteModpackPage.java index 2ace1017fe..9645536e84 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/RemoteModpackPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/RemoteModpackPage.java @@ -27,6 +27,7 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.RequiredValidator; import org.jackhuang.hmcl.ui.construct.Validator; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.util.SettingsMap; import org.jackhuang.hmcl.util.StringUtils; @@ -67,7 +68,8 @@ public RemoteModpackPage(WizardController controller) { txtModpackName.setText(manifest.getName().trim()); txtModpackName.getValidators().addAll( new RequiredValidator(), - new Validator(i18n("install.new_game.already_exists"), str -> !profile.getRepository().versionIdConflicts(str)), + new Validator(i18n("install.new_game.already_exists"), str -> + !profile.getRepository().versionIdConflicts(str) && !TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.MODPACK_INSTALL, str)), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId)); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java index 578cb24349..1670e34736 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java @@ -74,6 +74,19 @@ public Object finish(SettingsMap settings) { settings.put("success_message", i18n("install.success")); settings.put(FailureCallback.KEY, (settings1, exception, next) -> alertFailureMessage(exception, next)); + String detail = null; + for (Object value : settings.asStringMap().values()) { + if (value instanceof RemoteVersion remoteVersion) { + detail = i18n("task.detail.install_library", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()); + break; + } + } + if (detail == null) { + detail = i18n("task.detail.install_library.fallback", libraryId); + } + settings.put("task_detail", detail); + settings.put("backgroundable", true); + // We remove library but not save it, // so if installation failed will not break down current version. Task ret = Task.supplyAsync(() -> version); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java index 03d26e8723..4d86665e5b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.wizard.WizardController; @@ -70,6 +71,12 @@ public Object finish(SettingsMap settings) { settings.put("success_message", i18n("install.success")); settings.put(FailureCallback.KEY, (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next)); + String name = (String) settings.get("name"); + settings.put("task_detail", i18n("task.detail.install_game", name)); + settings.put("backgroundable", true); + settings.put("task_kind", TaskCenter.TaskKind.GAME_INSTALL); + settings.put("task_name", name); + return finishVersionDownloadingAsync(settings); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java index 54bcadbb11..8afad3f3e5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java @@ -46,6 +46,7 @@ import java.util.List; import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class ExportWizardProvider implements WizardProvider { private final Profile profile; @@ -68,6 +69,9 @@ public Object finish(SettingsMap settings) { exportInfo.setWhitelist(whitelist); String modpackType = settings.get(ModpackTypeSelectionPage.MODPACK_TYPE); + settings.put("backgroundable", true); + settings.put("task_detail", i18n("task.detail.modpack_export", exportInfo.getName())); + return exportWithLauncher(modpackType, exportInfo, modpackFile); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaDownloadDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaDownloadDialog.java index 1a9efa1dd1..0c5819fe41 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaDownloadDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaDownloadDialog.java @@ -200,7 +200,8 @@ protected void onAccept() { } }); - Controllers.taskDialog(task, i18n("download.java.process"), TaskCancellationAction.NORMAL); + Controllers.downloadTaskDialog(task, i18n("download.java.process"), TaskCancellationAction.NORMAL, + i18n("task.detail.java_download")); } } @@ -363,7 +364,7 @@ private void onDownload() { if (version == null) return; - Controllers.taskDialog(new GetTask(downloadProvider.injectURLWithCandidates(version.getLinks().getPkgInfoUri())) + Controllers.downloadTaskDialog(new GetTask(downloadProvider.injectURLWithCandidates(version.getLinks().getPkgInfoUri())) .setExecutor(Schedulers.io()) .thenComposeAsync(json -> { DiscoResult result = JsonUtils.fromNonNullJson(json, DiscoResult.typeOf(DiscoRemoteFileInfo.class)); @@ -425,7 +426,8 @@ else if (StringUtils.isNotBlank(fileInfo.getChecksumUri())) Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed")); } } - })), i18n("java.download"), TaskCancellationAction.NORMAL); + })), i18n("java.download"), TaskCancellationAction.NORMAL, + i18n("task.detail.java_download")); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java index 3393035fc6..a46fbde5ae 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -52,6 +52,7 @@ import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; @@ -326,6 +327,13 @@ private void launchNoGame() { .sorted() .findFirst() .orElseThrow(() -> new IOException("No versions found"))) + .thenComposeAsync(Schedulers.javafx(), version -> { + String gameVersion = version.getGameVersion(); + if (TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.GAME_INSTALL, gameVersion)) { + throw new CancellationException(i18n("install.new_game.already_exists")); + } + return Task.completed(version); + }) .thenComposeAsync(version -> { Profile profile = Profiles.getSelectedProfile(); DefaultDependencyManager dependency = profile.getDependency(); @@ -350,7 +358,8 @@ private void launchNoGame() { MessageDialogPane.MessageType.WARNING); } }); - Controllers.taskDialog(task, i18n("version.launch.empty.installing"), TaskCancellationAction.NORMAL); + Controllers.downloadTaskDialog(task, i18n("version.launch.empty.installing"), TaskCancellationAction.NORMAL, + i18n("task.detail.game_install")); } private void onUpgrade() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index 32e7b24ca8..4e307a416c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -46,6 +46,9 @@ import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider; import org.jackhuang.hmcl.ui.nbt.NBTEditorPage; import org.jackhuang.hmcl.ui.nbt.NBTFileType; +import org.jackhuang.hmcl.task.TaskExecutor; +import org.jackhuang.hmcl.ui.task.TaskCenter; +import org.jackhuang.hmcl.ui.task.TaskCenterPage; import org.jackhuang.hmcl.ui.versions.GameAdvancedListItem; import org.jackhuang.hmcl.ui.versions.GameListPopupMenu; import org.jackhuang.hmcl.ui.versions.Versions; @@ -189,6 +192,14 @@ protected Skin(RootPage control) { FXUtils.prepareOnMouseEnter(downloadItem, Controllers::prepareDownloadPage); } + AdvancedListItem taskManagerItem = new AdvancedListItem(); + taskManagerItem.setLeftIcon(SVG.CHECKLIST); + taskManagerItem.setTitle(i18n("task.manage")); + taskManagerItem.setOnAction(e -> { + Controllers.navigate(new TaskCenterPage()); + }); + FXUtils.installFastTooltip(taskManagerItem, i18n("task.manage.hint")); + // fifth item in left sidebar AdvancedListItem launcherSettingsItem = new AdvancedListItem(); launcherSettingsItem.setLeftIcon(SVG.SETTINGS); @@ -232,6 +243,7 @@ else if (Platform.SYSTEM_PLATFORM.equals(OperatingSystem.LINUX, Architecture.LOO .add(gameListItem) .add(gameItem) .add(downloadItem) + .add(taskManagerItem) .startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT)) .add(launcherSettingsItem) .add(terracottaItem) @@ -279,12 +291,17 @@ private void onRefreshedVersions(HMCLGameRepository repository) { if (modpackFile != null) { Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile)) .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding)) - .thenApplyAsync(modpack -> ModpackHelper - .getInstallTask(repository.getProfile(), modpackFile, modpack.getName(), modpack, null) - .executor()) - .thenAcceptAsync(Schedulers.javafx(), executor -> { - Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL); - executor.start(); + .thenAcceptAsync(Schedulers.javafx(), modpack -> { + String modpackName = modpack.getName(); + if (TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.MODPACK_INSTALL, modpackName)) { + Controllers.dialog(i18n("install.new_game.already_exists"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING); + return; + } + TaskExecutor executor = ModpackHelper + .getInstallTask(repository.getProfile(), modpackFile, modpackName, modpack, null) + .executor(); + Controllers.downloadTaskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL, + i18n("task.detail.modpack_install")); }).start(); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java index 40012b4426..d995b7d98a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java @@ -288,6 +288,15 @@ else if (locale.isSameLanguage(currentLocale)) settingsPane.getContent().add(disableAutoGameOptionsPane); } + { + LineToggleButton autoBackgroundTaskPane = new LineToggleButton(); + autoBackgroundTaskPane.setTitle(i18n("settings.launcher.auto_background_task")); + autoBackgroundTaskPane.setSubtitle(i18n("settings.launcher.auto_background_task.subtitle")); + autoBackgroundTaskPane.selectedProperty().bindBidirectional(config().autoBackgroundTaskProperty()); + + settingsPane.getContent().add(autoBackgroundTaskPane); + } + { BorderPane debugPane = new BorderPane(); @@ -302,7 +311,7 @@ else if (locale.isSameLanguage(currentLocale)) openLogFolderButton.setDisable(true); JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export")); - logButton.setOnAction(e -> onExportLogs()); + logButton.setOnAction(e -> exportLogs()); HBox buttonBox = new HBox(); buttonBox.setSpacing(10); @@ -329,7 +338,7 @@ private void onUpdate() { UpdateHandler.updateFrom(target); } - private static String getEntryName(Set entryNames, String name) { + public static String getEntryName(Set entryNames, String name) { if (entryNames.add(name)) { return name; } @@ -347,7 +356,7 @@ private static String getEntryName(Set entryNames, String name) { /// If no exception occurs, this method returns `true`; /// If an exception occurs while reading from `input`, this method returns `false`; /// If an exception occurs while writing to `output`, this method will throw it as is. - private static boolean exportLogFile(ZipOutputStream output, + public static boolean exportLogFile(ZipOutputStream output, Path file, // For logging String entryName, InputStream input, @@ -378,7 +387,7 @@ private static boolean exportLogFile(ZipOutputStream output, } } - private void onExportLogs() { + public static void exportLogs() { thread(() -> { String nameBase = "hmcl-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")); List recentLogFiles = LOG.findRecentLogFiles(5); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/task/TaskCenter.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/task/TaskCenter.java new file mode 100644 index 0000000000..86ebcc1c33 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/task/TaskCenter.java @@ -0,0 +1,276 @@ +/* + * 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.task; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CancellationException; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import org.jackhuang.hmcl.task.AsyncTaskExecutor; +import org.jackhuang.hmcl.task.TaskExecutor; +import org.jackhuang.hmcl.task.TaskListener; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.util.platform.OperatingSystem; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public final class TaskCenter { + private static final TaskCenter INSTANCE = new TaskCenter(); + + public static TaskCenter getInstance() { + return INSTANCE; + } + + public enum TaskKind { + GAME_INSTALL, + MODPACK_INSTALL, + JAVA_DOWNLOAD, + MOD_UPDATE, + OTHER + } + + public static final class Entry { + private final TaskExecutor executor; + private final String title; + private final String detail; + private final TaskKind kind; + private final String name; + + public Entry(TaskExecutor executor, String title, String detail, TaskKind kind, String name) { + this.executor = executor; + this.title = title; + this.detail = detail; + this.kind = kind; + this.name = name; + } + + public TaskExecutor getExecutor() { + return executor; + } + + public String getTitle() { + return title; + } + + public String getDetail() { + return detail; + } + + public TaskKind getKind() { + return kind; + } + + public String getName() { + return name; + } + } + + private final ObservableList entries = FXCollections.observableArrayList(); + private final ObservableList completedEntries = FXCollections.observableArrayList(); + private final ObservableList failedEntries = FXCollections.observableArrayList(); + + private final Deque queue = new ArrayDeque<>(); + private final Map entryIndex = new HashMap<>(); + private final Map started = new HashMap<>(); + private Entry running; + + public ObservableList getEntries() { + return entries; + } + + public Entry getRunningEntry() { + return running; + } + + public ObservableList getCompletedEntries() { + return completedEntries; + } + + public ObservableList getFailedEntries() { + return failedEntries; + } + + private void assertFxThread() { + assert Platform.isFxApplicationThread() : "TaskCenter must be accessed from FX Application Thread"; + } + + public void enqueue(TaskExecutor executor, String title, String detail) { + enqueue(executor, title, detail, TaskKind.OTHER, null); + } + + public void enqueue(TaskExecutor executor, String title, String detail, TaskKind kind, String name) { + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> enqueue(executor, title, detail, kind, name)); + return; + } + + assertFxThread(); + + if (entryIndex.containsKey(executor)) { + return; + } + + // Deduplicate by kind+name (e.g. same game/modpack install) + if (kind != null && name != null && hasQueuedInstallName(kind, name)) { + return; + } + + // Deduplicate by detail (or title if detail is null) to prevent repeated downloads + String deduplicationKey = detail != null ? detail : title; + if (deduplicationKey != null) { + for (Entry existing : entries) { + String existingKey = existing.getDetail() != null ? existing.getDetail() : existing.getTitle(); + if (deduplicationKey.equals(existingKey)) { + return; + } + } + } + + Entry entry = new Entry(executor, title, detail, kind, name); + entryIndex.put(executor, entry); + entries.add(entry); + queue.add(entry); + tryStartNext(); + } + + private void tryStartNext() { + assertFxThread(); + + while (running == null) { + Entry next = queue.poll(); + if (next == null) return; + + TaskExecutor executor = next.getExecutor(); + if (Boolean.TRUE.equals(started.get(executor))) { + continue; + } + + started.put(executor, true); + running = next; + + executor.addTaskListener(new TaskListener() { + @Override + public void onStop(boolean success, TaskExecutor executor) { + Platform.runLater(() -> onTaskStopped(executor, success)); + } + }); + + // Only start if not already running externally (e.g. started by downloadTaskDialog before enqueue) + if (!(executor instanceof AsyncTaskExecutor ate && ate.isStarted())) { + executor.start(); + } + return; + } + } + + private void onTaskStopped(TaskExecutor executor, boolean success) { + assertFxThread(); + + Entry stoppedEntry = entryIndex.remove(executor); + started.remove(executor); + + if (stoppedEntry != null) { + entries.remove(stoppedEntry); + if (success) { + completedEntries.add(stoppedEntry); + } else { + failedEntries.add(stoppedEntry); + } + + // Show notification for background tasks + String detail = stoppedEntry.getDetail() != null ? stoppedEntry.getDetail() : stoppedEntry.getTitle(); + if (success) { + Controllers.showToast(i18n("task.toast.success", detail)); + } else if (stoppedEntry.getExecutor().getException() instanceof CancellationException) { + Controllers.showToast(i18n("task.toast.cancelled", detail)); + } else { + Controllers.showToast(i18n("task.toast.failed", detail)); + if (!Controllers.isDialogShowing()) { + TaskCenterPage.showFailedTaskDialog(stoppedEntry); + } + } + } + + if (running != null && running.getExecutor() == executor) { + running = null; + } + + tryStartNext(); + } + + public boolean contains(TaskExecutor executor) { + assertFxThread(); + return entryIndex.containsKey(executor); + } + + public boolean isStarted(TaskExecutor executor) { + assertFxThread(); + return Boolean.TRUE.equals(started.get(executor)); + } + + public boolean hasQueuedInstallName(TaskKind kind, String name) { + assertFxThread(); + + if (name == null || kind == null) { + return false; + } + for (Entry entry : entries) { + if (entry.getKind() != kind || entry.getName() == null) { + continue; + } + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + if (entry.getName().equalsIgnoreCase(name)) return true; + } else { + if (entry.getName().equals(name)) return true; + } + } + return false; + } + + public boolean cancelQueued(TaskExecutor executor) { + assertFxThread(); + + Entry entry = entryIndex.get(executor); + if (entry == null) { + return false; + } + + if (Boolean.TRUE.equals(started.get(executor))) { + // Task is already running — cancel it properly + executor.cancel(); + } + + queue.remove(entry); + entries.remove(entry); + entryIndex.remove(executor); + started.remove(executor); + + if (running != null && running.getExecutor() == executor) { + running = null; + } + + failedEntries.add(entry); + tryStartNext(); + return true; + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/task/TaskCenterPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/task/TaskCenterPage.java new file mode 100644 index 0000000000..36cfda04a1 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/task/TaskCenterPage.java @@ -0,0 +1,343 @@ +/* + * 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.task; + +import com.jfoenix.controls.JFXButton; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.ListChangeListener; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.*; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +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.animation.TransitionPane; +import org.jackhuang.hmcl.ui.main.SettingsPage; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.TaskCancellationAction; + +import java.util.Locale; +import java.util.concurrent.CancellationException; + +import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public final class TaskCenterPage extends DecoratorAnimatedPage implements DecoratorPage { + private final ReadOnlyObjectWrapper state = + new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("task.manage"))); + + private final TransitionPane transitionPane = new TransitionPane(); + + private final TabHeader.Tab runningTab = new TabHeader.Tab<>("taskRunningTab"); + private final TabHeader.Tab completedTab = new TabHeader.Tab<>("taskCompletedTab"); + private final TabHeader.Tab failedTab = new TabHeader.Tab<>("taskFailedTab"); + + private final VBox runningContainer = new VBox(10); + private final VBox completedContainer = new VBox(8); + private final VBox failedContainer = new VBox(8); + + private final Label runningEmpty = new Label(i18n("task.empty.running")); + private final Label completedEmpty = new Label(i18n("task.empty.completed")); + private final Label failedEmpty = new Label(i18n("task.empty.failed")); + + public TaskCenterPage() { + runningTab.setNodeSupplier(this::createRunningPane); + completedTab.setNodeSupplier(this::createCompletedPane); + failedTab.setNodeSupplier(this::createFailedPane); + + TabHeader tabHeader = new TabHeader(transitionPane, runningTab, completedTab, failedTab); + tabHeader.select(runningTab); + + AdvancedListBox sideBar = new AdvancedListBox() + .startCategory(i18n("task.manage").toUpperCase(Locale.ROOT)) + .addNavigationDrawerTab(tabHeader, runningTab, i18n("task.running"), SVG.ARROW_FORWARD) + .addNavigationDrawerTab(tabHeader, completedTab, i18n("task.completed"), SVG.CHECK) + .addNavigationDrawerTab(tabHeader, failedTab, i18n("task.failed"), SVG.CLOSE); + + FXUtils.setLimitWidth(sideBar, 200); + setLeft(sideBar); + + BorderPane contentWrapper = new BorderPane(); + contentWrapper.getStyleClass().add("card-non-transparent"); + contentWrapper.setPadding(new Insets(12)); + contentWrapper.setCenter(transitionPane); + + StackPane centerPane = new StackPane(contentWrapper); + centerPane.setPadding(new Insets(12)); + setCenter(centerPane); + + styleEmptyLabel(runningEmpty); + styleEmptyLabel(completedEmpty); + styleEmptyLabel(failedEmpty); + + TaskCenter.getInstance().getEntries().addListener( + (ListChangeListener) change -> rebuildRunning()); + TaskCenter.getInstance().getCompletedEntries().addListener( + (ListChangeListener) change -> rebuildCompleted()); + TaskCenter.getInstance().getFailedEntries().addListener( + (ListChangeListener) change -> rebuildFailed()); + } + + private static void styleEmptyLabel(Label label) { + label.setStyle("-fx-text-fill: -fx-secondary-text-color; -fx-font-size: 13px;"); + } + + private static StackPane createCenteredEmpty(Label label) { + StackPane pane = new StackPane(label); + pane.setAlignment(Pos.CENTER); + VBox.setVgrow(pane, Priority.ALWAYS); + return pane; + } + + private ScrollPane createRunningPane() { + ScrollPane scrollPane = new ScrollPane(runningContainer); + scrollPane.setFitToWidth(true); + scrollPane.setFitToHeight(true); + runningContainer.setPadding(new Insets(12)); + rebuildRunning(); + return scrollPane; + } + + private static HBox createClearToolbar(Runnable onClear) { + HBox toolbar = new HBox(); + toolbar.setAlignment(Pos.CENTER_LEFT); + toolbar.setPickOnBounds(false); + toolbar.setMinHeight(24); + toolbar.setMaxHeight(24); + toolbar.setPrefHeight(24); + toolbar.setStyle("-fx-border-color: -monet-outline-variant; -fx-border-width: 0 0 1 0;"); + + JFXButton btn = createToolbarButton2(i18n("task.clear"), SVG.DELETE, onClear); + btn.setMinHeight(24); + btn.setMaxHeight(24); + btn.setPrefHeight(24); + + toolbar.getChildren().setAll(btn); + return toolbar; + } + + private ScrollPane createCompletedPane() { + VBox wrapper = new VBox(8); + wrapper.setPadding(new Insets(12)); + + HBox toolbar = createClearToolbar(() -> + TaskCenter.getInstance().getCompletedEntries().clear()); + + wrapper.getChildren().addAll(toolbar, completedContainer); + VBox.setVgrow(completedContainer, Priority.ALWAYS); + + ScrollPane scrollPane = new ScrollPane(wrapper); + scrollPane.setFitToWidth(true); + scrollPane.setFitToHeight(true); + rebuildCompleted(); + return scrollPane; + } + + private ScrollPane createFailedPane() { + VBox wrapper = new VBox(8); + wrapper.setPadding(new Insets(12)); + + HBox toolbar = createClearToolbar(() -> + TaskCenter.getInstance().getFailedEntries().clear()); + + wrapper.getChildren().addAll(toolbar, failedContainer); + VBox.setVgrow(failedContainer, Priority.ALWAYS); + + ScrollPane scrollPane = new ScrollPane(wrapper); + scrollPane.setFitToWidth(true); + scrollPane.setFitToHeight(true); + rebuildFailed(); + return scrollPane; + } + + // ── rebuild ────────────────────────────────────────────────────── + + private void rebuildRunning() { + runningContainer.getChildren().clear(); + if (TaskCenter.getInstance().getEntries().isEmpty()) { + runningContainer.getChildren().add(createCenteredEmpty(runningEmpty)); + return; + } + for (TaskCenter.Entry entry : TaskCenter.getInstance().getEntries()) { + runningContainer.getChildren().add(createRunningCard(entry)); + } + } + + private void rebuildCompleted() { + completedContainer.getChildren().clear(); + if (TaskCenter.getInstance().getCompletedEntries().isEmpty()) { + completedContainer.getChildren().add(createCenteredEmpty(completedEmpty)); + return; + } + for (TaskCenter.Entry entry : TaskCenter.getInstance().getCompletedEntries()) { + completedContainer.getChildren().add(createHistoryItem(entry, true)); + } + } + + private void rebuildFailed() { + failedContainer.getChildren().clear(); + if (TaskCenter.getInstance().getFailedEntries().isEmpty()) { + failedContainer.getChildren().add(createCenteredEmpty(failedEmpty)); + return; + } + for (TaskCenter.Entry entry : TaskCenter.getInstance().getFailedEntries()) { + failedContainer.getChildren().add(createHistoryItem(entry, false)); + } + } + + // ── running card ───────────────────────────────────────────────── + + private Node createRunningCard(TaskCenter.Entry entry) { + VBox card = new VBox(6); + card.setPadding(new Insets(10, 14, 10, 14)); + card.setStyle("-fx-border-color: -monet-outline-variant; -fx-border-width: 0 0 1 0;"); + + HBox header = new HBox(8); + header.setAlignment(Pos.CENTER_LEFT); + + Label kindTag = createKindTag(entry.getKind()); + + String titleText = entry.getDetail() != null ? entry.getDetail() : entry.getTitle(); + Label titleLabel = new Label(titleText); + titleLabel.getStyleClass().add("title-label"); + HBox.setHgrow(titleLabel, Priority.ALWAYS); + titleLabel.setMaxWidth(Double.MAX_VALUE); + + boolean isRunning = TaskCenter.getInstance().getRunningEntry() == entry; + Label statusLabel = new Label(isRunning ? i18n("task.status.running") : i18n("task.waiting")); + statusLabel.getStyleClass().add("subtitle-label"); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.getStyleClass().add("dialog-cancel"); + cancelButton.setOnAction(e -> { + TaskCenter tc = TaskCenter.getInstance(); + if (tc.isStarted(entry.getExecutor())) { + entry.getExecutor().cancel(); + } else { + tc.cancelQueued(entry.getExecutor()); + } + e.consume(); + }); + cancelButton.setOnMouseClicked(e -> e.consume()); + + header.getChildren().addAll(kindTag, titleLabel, statusLabel, cancelButton); + card.getChildren().add(header); + + if (isRunning) { + TaskListPane taskListPane = new TaskListPane(); + taskListPane.setExecutor(entry.getExecutor()); + taskListPane.setMaxHeight(120); + taskListPane.setPrefHeight(120); + card.getChildren().add(taskListPane); + } + + card.setOnMouseClicked(e -> { + if (entry != TaskCenter.getInstance().getRunningEntry()) { + Controllers.dialog(i18n("task.waiting"), entry.getTitle(), MessageDialogPane.MessageType.INFO); + return; + } + TaskExecutorDialogPane pane = Controllers.taskDialog(entry.getExecutor(), entry.getTitle(), TaskCancellationAction.NORMAL); + pane.setEscAction(() -> pane.fireEvent(new DialogCloseEvent())); + pane.setCancelText(i18n("button.close")); + pane.setCancelAction(() -> pane.fireEvent(new DialogCloseEvent())); + pane.refreshTaskList(); + }); + + return card; + } + + // ── history item (completed / failed) ──────────────────────────── + + private Node createHistoryItem(TaskCenter.Entry entry, boolean success) { + HBox row = new HBox(8); + row.setPadding(new Insets(8, 12, 8, 12)); + row.setAlignment(Pos.CENTER_LEFT); + row.getStyleClass().add("md-list-cell"); + + boolean cancelled = !success && entry.getExecutor().getException() instanceof CancellationException; + Node icon; + if (success) { + icon = SVG.CHECK.createIcon(14); + } else if (cancelled) { + icon = SVG.CANCEL.createIcon(14); + } else { + icon = SVG.CLOSE.createIcon(14); + } + + Label kindTag = createKindTag(entry.getKind()); + + String text = entry.getDetail() != null ? entry.getDetail() : entry.getTitle(); + Label label = new Label(text); + label.getStyleClass().add("subtitle-label"); + HBox.setHgrow(label, Priority.ALWAYS); + label.setMaxWidth(Double.MAX_VALUE); + + row.getChildren().addAll(icon, kindTag, label); + + if (!success && !cancelled) { + row.setStyle("-fx-cursor: hand;"); + row.setOnMouseClicked(e -> showFailedTaskDialog(entry)); + } + + return row; + } + + // ── failed task dialog ───────────────────────────────────────────── + + static void showFailedTaskDialog(TaskCenter.Entry entry) { + Throwable ex = entry.getExecutor().getException(); + if (ex instanceof CancellationException) { + Controllers.dialog(i18n("task.cancelled"), entry.getTitle(), MessageDialogPane.MessageType.ERROR); + } else { + String message = ex != null + ? StringUtils.getStackTrace(ex) + : i18n("task.failed.no_exception"); + Controllers.dialog(new MessageDialogPane.Builder(message, entry.getTitle(), MessageDialogPane.MessageType.ERROR) + .addAction(i18n("settings.launcher.launcher_log.export"), SettingsPage::exportLogs) + .ok(null) + .build()); + } + } + + // ── kind tag ───────────────────────────────────────────────────── + + private static Label createKindTag(TaskCenter.TaskKind kind) { + String text = switch (kind) { + case GAME_INSTALL -> i18n("task.kind.game_install"); + case MODPACK_INSTALL -> i18n("task.kind.modpack_install"); + case JAVA_DOWNLOAD -> i18n("task.kind.java_download"); + case MOD_UPDATE -> i18n("task.kind.mod_update"); + case OTHER -> i18n("task.kind.other"); + }; + Label tag = new Label(text); + tag.getStyleClass().add("tag"); + return tag; + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + return state.getReadOnlyProperty(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java index ed2e4a8d42..d1780994e8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java @@ -196,14 +196,15 @@ public void saveAs(RemoteMod mod, RemoteMod.Version file) { return; } - Controllers.taskDialog( + Controllers.downloadTaskDialog( Task.composeAsync(() -> { var task = new FileDownloadTask(file.getFile().getUrl(), dest, file.getFile().getIntegrityCheck()); task.setName(file.getName()); return task; }), i18n("message.downloading"), - TaskCancellationAction.NORMAL); + TaskCancellationAction.NORMAL, + i18n("message.downloading") + " - " + file.getName()); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java index c8d8791f16..29344bcced 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java @@ -161,8 +161,8 @@ public void onStop(boolean success, TaskExecutor executor) { }); } }); - Controllers.taskDialog(executor, i18n("install.installer.install_offline"), TaskCancellationAction.NO_CANCEL); - executor.start(); + Controllers.downloadTaskDialog(executor, i18n("install.installer.install_offline"), TaskCancellationAction.NO_CANCEL, + i18n("task.detail.install_offline")); } private class InstallerListPageSkin extends ToolbarListPageSkin { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 3c738da352..d04b6f4ed4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -237,7 +237,7 @@ public void openModFolder() { public void checkUpdates(Collection mods) { Objects.requireNonNull(mods); - Runnable action = () -> Controllers.taskDialog(Task + Runnable action = () -> Controllers.downloadTaskDialog(Task .composeAsync(() -> { Optional gameVersion = profile.getRepository().getGameVersion(instanceId); if (gameVersion.isPresent()) { @@ -256,7 +256,8 @@ public void checkUpdates(Collection mods) { } }) .withStagesHints("update.checking"), - i18n("mods.check_updates"), TaskCancellationAction.NORMAL); + i18n("mods.check_updates"), TaskCancellationAction.NORMAL, + i18n("task.detail.mod_check_updates")); if (profile.getRepository().isModpack(instanceId)) { Controllers.confirm( diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java index c0c0a2f46f..bb0d0a65ff 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java @@ -136,7 +136,7 @@ private void updateMods() { .filter(o -> o.enabled.get()) .map(object -> pair(object.data.getLocalMod(), object.data.getCandidate())) .collect(Collectors.toList())); - Controllers.taskDialog( + Controllers.downloadTaskDialog( task.whenComplete(Schedulers.javafx(), exception -> { fireEvent(new PageCloseEvent()); if (!task.getFailedMods().isEmpty()) { @@ -151,7 +151,8 @@ private void updateMods() { } }), i18n("mods.check_updates"), - TaskCancellationAction.NORMAL); + TaskCancellationAction.NORMAL, + i18n("task.detail.mod_update")); } private void exportList() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java index ae72ab4854..b27d672529 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java @@ -32,6 +32,7 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.account.CreateAccountPane; import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; @@ -85,7 +86,7 @@ public static void downloadModpackImpl(DownloadProvider downloadProvider, Profil i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR); return; } - Controllers.taskDialog( + Controllers.downloadTaskDialog( new FileDownloadTask(downloadURLs, modpack) .whenComplete(Schedulers.javafx(), e -> { if (e == null) { @@ -106,7 +107,8 @@ public static void downloadModpackImpl(DownloadProvider downloadProvider, Profil } }), i18n("message.downloading"), - TaskCancellationAction.NORMAL + TaskCancellationAction.NORMAL, + i18n("task.detail.install_modpack", modpack.getFileName().toString()) ); } @@ -147,7 +149,10 @@ public static CompletableFuture renameVersion(Profile profile, String ve handler.reject(i18n("version.manage.rename.fail")); } }, version, new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId), - new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName) || newVersionName.equals(version))); + new Validator(i18n("install.new_game.already_exists"), newVersionName -> + (!profile.getRepository().versionIdConflicts(newVersionName) || newVersionName.equals(version)) + && !TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.GAME_INSTALL, newVersionName) + && !TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.MODPACK_INSTALL, newVersionName))); } public static void exportVersion(Profile profile, String version) { @@ -179,7 +184,10 @@ public static void duplicateVersion(Profile profile, String version) { .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("version.manage.duplicate.confirm"))) .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, version, new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId), - new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName)))) + new Validator(i18n("install.new_game.already_exists"), newVersionName -> + !profile.getRepository().versionIdConflicts(newVersionName) + && !TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.GAME_INSTALL, newVersionName) + && !TaskCenter.getInstance().hasQueuedInstallName(TaskCenter.TaskKind.MODPACK_INSTALL, newVersionName)))) .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("version.manage.duplicate.duplicate_save"), false))); } @@ -190,8 +198,8 @@ public static void updateVersion(Profile profile, String version) { public static void updateGameAssets(Profile profile, String version) { TaskExecutor executor = new GameAssetDownloadTask(profile.getDependency(), profile.getRepository().getVersion(version), GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true) .executor(); - Controllers.taskDialog(executor, i18n("version.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL); - executor.start(); + Controllers.downloadTaskDialog(executor, i18n("version.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL, + i18n("task.detail.redownload_assets", version)); } public static void cleanVersion(Profile profile, String id) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 0a93925726..476aacd16f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -128,7 +128,7 @@ protected Skin createDefaultSkin() { } void createBackup() { - Controllers.taskDialog(new WorldBackupTask(world, backupsDir, false).setName(i18n("world.backup.processing")).thenApplyAsync(path -> { + Controllers.downloadTaskDialog(new WorldBackupTask(world, backupsDir, false).setName(i18n("world.backup.processing")).thenApplyAsync(path -> { Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString()); if (!matcher.matches()) { throw new AssertionError("Wrong backup file name" + path); @@ -153,7 +153,8 @@ void createBackup() { LOG.warning("Failed to create backup", exception); Controllers.dialog(i18n("world.backup.create.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING); } - }), i18n("world.backup"), null); + }), i18n("world.backup"), null, + i18n("task.detail.world_backup", world.getWorldName())); } private final class WorldBackupsPageSkin extends ToolbarListPageSkin { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldExportPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldExportPage.java index d7fee2c151..660f922f06 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldExportPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldExportPage.java @@ -28,6 +28,8 @@ import java.nio.file.Path; import java.nio.file.Paths; +import org.jackhuang.hmcl.util.SettingsMap; + import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class WorldExportPage extends WizardSinglePage { @@ -73,6 +75,12 @@ public String getTitle() { return i18n("world.export.wizard", world.getFileName()); } + @Override + protected void onFinishSettings(SettingsMap settings) { + settings.put("backgroundable", true); + settings.put("task_detail", i18n("task.detail.world_export", worldName.get())); + } + @Override protected Object finish() { return Task.runAsync(i18n("world.export.wizard", worldName.get()), () -> world.export(Paths.get(path.get()), worldName.get())); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/SinglePageWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/SinglePageWizardProvider.java index c30e41affe..f608867625 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/SinglePageWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/SinglePageWizardProvider.java @@ -37,6 +37,7 @@ public void start(SettingsMap settings) { @Override public Object finish(SettingsMap settings) { + page.onFinishSettings(settings); return page.finish(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/TaskExecutorDialogWizardDisplayer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/TaskExecutorDialogWizardDisplayer.java index cf5f128ebd..031048b201 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/TaskExecutorDialogWizardDisplayer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/TaskExecutorDialogWizardDisplayer.java @@ -19,6 +19,7 @@ import javafx.beans.property.StringProperty; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.task.TaskCenter; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.task.TaskListener; import org.jackhuang.hmcl.ui.Controllers; @@ -32,6 +33,7 @@ import java.util.Queue; import java.util.concurrent.CancellationException; +import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -58,10 +60,18 @@ else if (title instanceof String titleMessage) } runInFX(() -> { + boolean backgroundable = Boolean.TRUE.equals(settings.get("backgroundable")); + + // Track whether this task has been moved to background + final boolean[] movedToBackground = {false}; + TaskExecutor executor = task.executor(new TaskListener() { @Override public void onStop(boolean success, TaskExecutor executor) { runInFX(() -> { + // If task was moved to background, TaskCenter handles notifications + if (movedToBackground[0]) return; + if (success) { if (settings.get("success_message") instanceof String successMessage) Controllers.dialog(successMessage, null, MessageType.SUCCESS, () -> onEnd()); @@ -84,13 +94,75 @@ else if (settings.get("failure_message") instanceof String failureMessage) else if (!settings.containsKey("forbid_failure_message")) Controllers.dialog(appendix, i18n("wizard.failed"), MessageType.ERROR, () -> onEnd()); } - }); } }); + + if (backgroundable) { + Object detailObj = settings.get("task_detail"); + String detail = detailObj != null ? detailObj.toString() : pane.getTitle(); + + TaskCenter.TaskKind kind = (TaskCenter.TaskKind) settings.get("task_kind"); + String taskName = (String) settings.get("task_name"); + + if (config().isAutoBackgroundTask()) { + // Auto-background: enqueue directly without showing dialog + movedToBackground[0] = true; + TaskCenter.getInstance().enqueue(executor, pane.getTitle(), detail, kind, taskName); + Controllers.showToast(i18n("task.auto_background.enqueued", detail != null ? detail : pane.getTitle())); + // onEnd() cleans up wizard state and navigates back to the parent page + // (e.g. game install list, modpack list, mod page, etc.) + // movedToBackground prevents onStop from navigating again on task completion + onEnd(); + return; + } + + Runnable moveToBackground = () -> { + movedToBackground[0] = true; + TaskCenter.getInstance().enqueue(executor, pane.getTitle(), detail, kind, taskName); + + boolean returnToDownloadList = Boolean.TRUE.equals(settings.get("return_to_download_list")); + onEnd(); + if (returnToDownloadList) { + Controllers.getDownloadPage().showGameDownloads(); + Controllers.navigate(Controllers.getDownloadPage()); + } + + pane.fireEvent(new DialogCloseEvent()); + }; + + // Manual mode: check if background tasks are running, wait if so + if (!TaskCenter.getInstance().getEntries().isEmpty()) { + pane.setWaitingForBackground(true); + TaskCenter.getInstance().getEntries().addListener( + (javafx.collections.ListChangeListener) change -> { + if (TaskCenter.getInstance().getEntries().isEmpty()) { + pane.setWaitingForBackground(false); + executor.start(); + pane.refreshTaskList(); + } + }); + } + pane.setBackgroundAction(moveToBackground); + } + pane.setExecutor(executor); + + pane.addEventHandler(DialogCloseEvent.CLOSE, event -> { + boolean returnToDownloadList = Boolean.TRUE.equals(settings.get("return_to_download_list")); + if (returnToDownloadList) { + onEnd(); + Controllers.getDownloadPage().showGameDownloads(); + Controllers.navigate(Controllers.getDownloadPage()); + } + }); + Controllers.dialog(pane); - executor.start(); + + if (!backgroundable || TaskCenter.getInstance().getEntries().isEmpty()) { + executor.start(); + pane.refreshTaskList(); + } }); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/WizardSinglePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/WizardSinglePage.java index d6c2673c5f..7850f8f81b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/WizardSinglePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/wizard/WizardSinglePage.java @@ -29,6 +29,13 @@ protected WizardSinglePage(Runnable onFinish) { protected abstract Object finish(); + /** + * Called before {@link #finish()} to allow subclasses to inject settings + * (e.g. backgroundable, task_detail) into the wizard settings map. + */ + protected void onFinishSettings(SettingsMap settings) { + } + @Override public void cleanup(SettingsMap settings) { } diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 5b035f97f6..f619b4cf16 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -331,6 +331,15 @@ -fx-pref-height: 0; } +.tag { + -fx-text-fill: -monet-on-secondary-container; + -fx-background-color: -monet-secondary-container; + -fx-background-radius: 2; + -fx-padding: 2; + -fx-font-weight: normal; + -fx-font-size: 12px; +} + .two-line-list-item > .first-line .tag { -fx-text-fill: -monet-on-secondary-container; -fx-background-color: -monet-secondary-container; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 605e328b8c..65aa9d637c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1425,6 +1425,8 @@ settings.launcher.common_path.tooltip=HMCL will put all game assets and dependen settings.launcher.debug=Debug settings.launcher.disable_april_fools=Do not enable April Fools features settings.launcher.disable_auto_game_options=Do not switch game language +settings.launcher.auto_background_task=Automatically move tasks to background +settings.launcher.auto_background_task.subtitle=Supported tasks will be automatically moved to the background task queue after starting settings.launcher.download=Download settings.launcher.download.threads=Threads settings.launcher.download.threads.auto=Automatically Determine @@ -1492,6 +1494,51 @@ sponsor.hmcl=Hello Minecraft! Launcher is a FOSS Minecraft launcher that allows system.architecture=Architecture system.operating_system=Operating System +button.close=Close +task.cancelled=Task cancelled by user +task.clear=Clear +task.completed=Completed +task.detail.download=Download - %s +task.detail.game_install=Quick Install Game +task.detail.install_game=Install Game - %s +task.detail.install_library=Install %1$s - %2$s +task.detail.install_library.fallback=Install %s +task.detail.install_mod=Install Mod - %s +task.detail.install_modpack=Install Modpack - %s +task.detail.install_offline=Install Offline Installer +task.detail.install_resourcepack=Install Resource Pack - %s +task.detail.install_shaderpack=Install Shader Pack - %s +task.detail.install_world=Install World - %s +task.detail.java_download=Download Java Runtime +task.detail.mod_check_updates=Check Mod Updates +task.detail.mod_update=Update Mods +task.detail.modpack_export=Export Modpack - %s +task.detail.modpack_install=Install Modpack +task.detail.redownload_assets=Redownload Assets - %s +task.detail.world_backup=Backup World - %s +task.detail.world_export=Export World - %s +task.empty.completed=No completed tasks +task.empty.failed=No failed tasks +task.empty.running=No running tasks +task.failed=Failed +task.failed.no_exception=Task failed (no exception details) +task.kind.game_install=Game +task.kind.java_download=Java +task.kind.mod_update=Mod +task.kind.modpack_install=Modpack +task.kind.other=Task +task.auto_background.enqueued="%1$s" has been added to the background task queue +task.manage=Task Manager +task.manage.hint=View and manage background download tasks +task.move_to_background=Move Task to Background +task.running=Running Tasks +task.status.running=Running +task.toast.cancelled=Task cancelled: %s +task.toast.failed=Task failed: %s +task.toast.success=Task completed: %s +task.unnamed=Unnamed Task +task.waiting=Task is waiting in queue +task.waiting_for_background=There are background tasks in progress. This task will start automatically after they are completed. terracotta=Multiplayer terracotta.terracotta=Terracotta | Multiplayer terracotta.status=Lobby diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 0f7d9c6633..1b372e7e2b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1210,6 +1210,8 @@ settings.launcher.common_path.tooltip=啟動器將所有遊戲資源及相依元 settings.launcher.debug=除錯 settings.launcher.disable_april_fools=不啟用愚人節功能 settings.launcher.disable_auto_game_options=不自動切換遊戲語言 +settings.launcher.auto_background_task=自動將任務移至後臺 +settings.launcher.auto_background_task.subtitle=支援後臺處理的任務啟動後將自動加入後臺任務佇列 settings.launcher.download=下載 settings.launcher.download.threads=執行緒數 settings.launcher.download.threads.auto=自動選取執行緒數 @@ -1277,6 +1279,52 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一個免費、自由、開源的 Mine system.architecture=架構 system.operating_system=作業系統 +button.close=關閉 +task.cancelled=任務由使用者取消 +task.clear=清空 +task.completed=已完成 +task.detail.download=下載 - %s +task.detail.game_install=快速安裝遊戲 +task.detail.install_game=安裝遊戲 - %s +task.detail.install_library=安裝%1$s - %2$s +task.detail.install_library.fallback=安裝%s +task.detail.install_mod=安裝模組 - %s +task.detail.install_modpack=安裝整合包 - %s +task.detail.install_offline=安裝離線安裝器 +task.detail.install_resourcepack=安裝資源包 - %s +task.detail.install_shaderpack=安裝光影 - %s +task.detail.install_world=安裝世界 - %s +task.detail.java_download=下載 Java 執行環境 +task.detail.mod_check_updates=檢查模組更新 +task.detail.mod_update=更新模組 +task.detail.modpack_export=匯出整合包 - %s +task.detail.modpack_install=安裝整合包 +task.detail.redownload_assets=重新下載資源 - %s +task.detail.world_backup=備份世界 - %s +task.detail.world_export=匯出世界 - %s +task.empty.completed=暫無已完成的任務 +task.empty.failed=暫無失敗的任務 +task.empty.running=暫無正在執行的任務 +task.failed=失敗 +task.failed.no_exception=任務失敗(無異常資訊) +task.kind.game_install=遊戲 +task.kind.java_download=Java +task.kind.mod_update=模組 +task.kind.modpack_install=整合包 +task.kind.other=任務 +task.auto_background.enqueued="%1$s" 已加入後臺任務佇列 +task.manage=任務管理器 +task.manage.hint=檢視和管理後臺下載任務 +task.move_to_background=將任務移至後臺 +task.running=正在執行的任務 +task.status.running=執行中 +task.toast.cancelled=任務已取消:%s +task.toast.failed=任務失敗:%s +task.toast.success=任務完成:%s +task.unnamed=未命名任務 +task.waiting=任務正在佇列中等待 +task.waiting_for_background=有後臺任務正在進行中,本任務將在後臺任務全部完成後自動開始。 + terracotta=多人遊戲 terracotta.terracotta=Terracotta | 陶瓦聯機 terracotta.status=聯機大廳 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..a3962f77a4 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1215,6 +1215,8 @@ settings.launcher.common_path.tooltip=启动器将所有游戏资源及依赖库 settings.launcher.debug=调试 settings.launcher.disable_april_fools=不启用愚人节功能 settings.launcher.disable_auto_game_options=不自动切换游戏语言 +settings.launcher.auto_background_task=自动将任务移至后台 +settings.launcher.auto_background_task.subtitle=支持后台处理的任务启动后将自动加入后台任务队列 settings.launcher.download=下载 settings.launcher.download.threads=线程数 settings.launcher.download.threads.auto=自动选择线程数 @@ -1282,6 +1284,52 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一个免费、自由、开放源代 system.architecture=架构 system.operating_system=操作系统 +button.close=关闭 +task.cancelled=任务由用户取消 +task.clear=清空 +task.completed=已完成 +task.detail.download=下载 - %s +task.detail.game_install=快速安装游戏 +task.detail.install_game=安装游戏 - %s +task.detail.install_library=安装%1$s - %2$s +task.detail.install_library.fallback=安装%s +task.detail.install_mod=安装模组 - %s +task.detail.install_modpack=安装整合包 - %s +task.detail.install_offline=安装离线安装器 +task.detail.install_resourcepack=安装资源包 - %s +task.detail.install_shaderpack=安装光影 - %s +task.detail.install_world=安装世界 - %s +task.detail.java_download=下载 Java 运行时 +task.detail.mod_check_updates=检查模组更新 +task.detail.mod_update=更新模组 +task.detail.modpack_export=导出整合包 - %s +task.detail.modpack_install=安装整合包 +task.detail.redownload_assets=重新下载资源 - %s +task.detail.world_backup=备份世界 - %s +task.detail.world_export=导出世界 - %s +task.empty.completed=暂无已完成的任务 +task.empty.failed=暂无失败的任务 +task.empty.running=暂无正在运行的任务 +task.failed=失败 +task.failed.no_exception=任务失败(无异常信息) +task.kind.game_install=游戏 +task.kind.java_download=Java +task.kind.mod_update=模组 +task.kind.modpack_install=整合包 +task.kind.other=任务 +task.auto_background.enqueued="%1$s" 已加入后台任务队列 +task.manage=任务管理器 +task.manage.hint=查看和管理后台下载任务 +task.move_to_background=将任务移至后台 +task.running=正在运行的任务 +task.status.running=运行中 +task.toast.cancelled=任务已取消:%s +task.toast.failed=任务失败:%s +task.toast.success=任务完成:%s +task.unnamed=未命名任务 +task.waiting=任务正在队列中等待 +task.waiting_for_background=有后台任务正在进行中,本任务将在后台任务全部完成后自动开始。 + terracotta=多人联机 terracotta.terracotta=Terracotta | 陶瓦联机 terracotta.status=联机大厅 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/AsyncTaskExecutor.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/AsyncTaskExecutor.java index fe16a8a2a1..7df8e1df80 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/AsyncTaskExecutor.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/AsyncTaskExecutor.java @@ -33,7 +33,7 @@ */ public final class AsyncTaskExecutor extends TaskExecutor { - private CompletableFuture future; + private volatile CompletableFuture future; public AsyncTaskExecutor(Task task) { super(task); @@ -334,4 +334,8 @@ private static Exception convertInterruptedException(Exception e) { public static void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler uncaughtExceptionHandler) { AsyncTaskExecutor.uncaughtExceptionHandler = uncaughtExceptionHandler; } + + public boolean isStarted() { + return future != null; + } }