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 e70a19130..bbc9a319a 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 @@ -212,6 +212,10 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage tab.select(modpackTab); } + public void showResourcepackDownloads() { + tab.select(resourcePackTab); + } + public DownloadListPage showModDownloads() { tab.select(modTab); return modTab.getNode(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java new file mode 100644 index 000000000..3bff0edb4 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java @@ -0,0 +1,231 @@ +package org.jackhuang.hmcl.ui.versions; + +import com.jfoenix.controls.JFXButton; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Control; +import javafx.scene.control.Skin; +import javafx.scene.control.SkinBase; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.stage.FileChooser; +import org.jackhuang.hmcl.resourcepack.ResourcepackFile; +import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.*; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; +import org.jackhuang.hmcl.ui.construct.RipplerContainer; +import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public class ResourcepackListPage extends ListPageBase implements VersionPage.VersionLoadable { + private Path resourcepackDirectory; + + public ResourcepackListPage() { + FXUtils.applyDragListener(this, file -> file.isFile() && file.getName().endsWith(".zip"), files -> addFiles(files.stream().map(File::toPath).collect(Collectors.toList()))); + } + + private static Node createIcon(Path img) { + ImageView imageView = new ImageView(); + imageView.setFitWidth(32); + imageView.setFitHeight(32); + + if (Files.exists(img)) { + try (InputStream is = Files.newInputStream(img)) { + Image image = new Image(is); + imageView.setImage(image); + } catch (IOException ignored) { + } + } + + if (imageView.getImage() == null) { + imageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png")); + } + + return imageView; + } + + @Override + protected Skin createDefaultSkin() { + return new ResourcepackListPageSkin(this); + } + + @Override + public void loadVersion(Profile profile, String version) { + this.resourcepackDirectory = profile.getRepository().getResourcepacksDirectory(version); + + try { + if (!Files.exists(resourcepackDirectory)) { + Files.createDirectories(resourcepackDirectory); + } + } catch (IOException e) { + LOG.error("Failed to create resourcepack directory", e); + } + refresh(); + } + + public void refresh() { + Task.runAsync(Schedulers.javafx(), this::load).whenComplete(Schedulers.javafx(), (result, exception) -> setLoading(false)).start(); + setLoading(true); + } + + public void addFiles(List files) { + if (resourcepackDirectory == null) return; + + try { + for (Path file : files) { + Path target = resourcepackDirectory.resolve(file.getFileName()); + if (!Files.exists(target)) { + Files.copy(file, target); + } + } + } catch (IOException e) { + Controllers.dialog(i18n("resourcepack.add.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + LOG.warning("Failed to add resourcepacks", e); + } + } + + public void onAddFiles() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle(i18n("resourcepack.add")); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip")); + List files = fileChooser.showOpenMultipleDialog(Controllers.getStage()); + if (files != null && !files.isEmpty()) { + addFiles(files.stream().map(File::toPath).collect(Collectors.toList())); + } + } + + private void load() { + itemsProperty().clear(); + if (resourcepackDirectory == null || !Files.exists(resourcepackDirectory)) return; + + try (Stream stream = Files.list(resourcepackDirectory)) { + stream.forEach(path -> { + try { + itemsProperty().add(new ResourcepackItem(ResourcepackFile.parse(path))); + } catch (Exception e) { + LOG.warning("Failed to load resourcepacks " + path.getFileName(), e); + } + }); + } catch (IOException e) { + LOG.warning("Failed to list resourcepacks directory", e); + } + + itemsProperty().sort(Comparator.comparing(item -> item.getFile().getName())); + } + + private void onDownload() { + runInFX(() -> { + Controllers.getDownloadPage().showResourcepackDownloads(); + Controllers.navigate(Controllers.getDownloadPage()); + }); + } + + private static class ResourcepackListPageSkin extends ToolbarListPageSkin { + protected ResourcepackListPageSkin(ResourcepackListPage control) { + super(control); + } + + @Override + protected List initializeToolbar(ResourcepackListPage skinnable) { + return Arrays.asList(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, skinnable::onAddFiles), createToolbarButton2(i18n("resourcepack.download"), SVG.DOWNLOAD, skinnable::onDownload)); + } + } + + public class ResourcepackItem extends Control { + private final ResourcepackFile file; +// final JFXCheckBox checkBox = new JFXCheckBox(); + + public ResourcepackItem(ResourcepackFile file) { + this.file = file; + } + + @Override + protected Skin createDefaultSkin() { + return new ResourcepackItemSkin(this); + } + + public void onDelete() { + try { + if (file.getFile().isDirectory()) { + FileUtils.deleteDirectory(file.getFile()); + } else { + Files.delete(file.getFile().toPath()); + } + ResourcepackListPage.this.refresh(); + } catch (IOException e) { + Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + LOG.warning("Failed to delete resourcepack", e); + } + } + + public void onReveal() { + FXUtils.showFileInExplorer(file.getFile().toPath()); + } + + public ResourcepackFile getFile() { + return file; + } + } + + private class ResourcepackItemSkin extends SkinBase { + public ResourcepackItemSkin(ResourcepackItem item) { + super(item); + BorderPane root = new BorderPane(); + root.getStyleClass().add("md-list-cell"); + root.setPadding(new Insets(8)); + + HBox left = new HBox(8); + left.setAlignment(Pos.CENTER); + left.getChildren().addAll(createIcon(item.getFile().getIcon())); +// left.getChildren().addAll(item.checkBox, createIcon(item.getFile().getIcon())); + left.setPadding(new Insets(0, 8, 0, 0)); +// FXUtils.setLimitWidth(left, 64); + FXUtils.setLimitWidth(left, 48); + root.setLeft(left); + + TwoLineListItem center = new TwoLineListItem(); +// center.setPadding(new Insets(0, 0, 0, 8)); + center.setTitle(item.getFile().getName()); + center.setSubtitle(item.getFile().getDescription()); + root.setCenter(center); + + HBox right = new HBox(8); + right.setAlignment(Pos.CENTER_RIGHT); + JFXButton btnReveal = new JFXButton(); + FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager")); + btnReveal.getStyleClass().add("toggle-icon4"); + btnReveal.setGraphic(SVG.FOLDER_OPEN.createIcon(Theme.blackFill(), -1)); + btnReveal.setOnAction(event -> item.onReveal()); + + JFXButton btnDelete = new JFXButton(); + btnDelete.getStyleClass().add("toggle-icon4"); + btnDelete.setGraphic(SVG.DELETE_FOREVER.createIcon(Theme.blackFill(), -1)); + btnDelete.setOnAction(event -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), item::onDelete, null)); + right.getChildren().setAll(btnReveal, btnDelete); + root.setRight(right); + + this.getChildren().add(new RipplerContainer(root)); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java index bb129624a..26090d8ee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java @@ -59,6 +59,7 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage private final TabHeader.Tab modListTab = new TabHeader.Tab<>("modListTab"); private final TabHeader.Tab worldListTab = new TabHeader.Tab<>("worldList"); private final TabHeader.Tab schematicsTab = new TabHeader.Tab<>("schematicsTab"); + private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentVersionUpgradable = new SimpleBooleanProperty(); private final ObjectProperty version = new SimpleObjectProperty<>(); @@ -72,8 +73,9 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage modListTab.setNodeSupplier(loadVersionFor(ModListPage::new)); worldListTab.setNodeSupplier(loadVersionFor(WorldListPage::new)); schematicsTab.setNodeSupplier(loadVersionFor(SchematicsPage::new)); + resourcePackTab.setNodeSupplier(loadVersionFor(ResourcepackListPage::new)); - tab = new TabHeader(versionSettingsTab, installerListTab, modListTab, worldListTab, schematicsTab); + tab = new TabHeader(versionSettingsTab, installerListTab, modListTab, worldListTab, schematicsTab, resourcePackTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -138,6 +140,8 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage worldListTab.getNode().loadVersion(profile, version); if (schematicsTab.isInitialized()) schematicsTab.getNode().loadVersion(profile, version); + if (resourcePackTab.isInitialized()) + resourcePackTab.getNode().loadVersion(profile, version); currentVersionUpgradable.set(profile.getRepository().isModpack(version)); } @@ -282,11 +286,20 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage schematicsListItem.activeProperty().bind(control.tab.getSelectionModel().selectedItemProperty().isEqualTo(control.schematicsTab)); schematicsListItem.setOnAction(e -> control.tab.select(control.schematicsTab)); + AdvancedListItem resourcePackListItem = new AdvancedListItem(); + resourcePackListItem.getStyleClass().add("navigation-drawer-item"); + resourcePackListItem.setTitle(i18n("resourcepack.manage")); + resourcePackListItem.setLeftGraphic(wrap(SVG.TEXTURE)); + resourcePackListItem.setActionButtonVisible(false); + resourcePackListItem.activeProperty().bind(control.tab.getSelectionModel().selectedItemProperty().isEqualTo(control.resourcePackTab)); + resourcePackListItem.setOnAction(e -> control.tab.select(control.resourcePackTab)); + AdvancedListBox sideBar = new AdvancedListBox() .add(versionSettingsItem) .add(installerListItem) .add(modListItem) .add(worldListItem) + .add(resourcePackListItem) .add(schematicsListItem); VBox.setVgrow(sideBar, Priority.ALWAYS); diff --git a/HMCL/src/main/resources/assets/img/unknown_pack.png b/HMCL/src/main/resources/assets/img/unknown_pack.png new file mode 100644 index 000000000..b741909b1 Binary files /dev/null and b/HMCL/src/main/resources/assets/img/unknown_pack.png differ diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 56064f3d5..acb893ab2 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1200,6 +1200,11 @@ repositories.chooser=HMCL requires JavaFX to work.\n\ repositories.chooser.title=Choose download source for JavaFX resourcepack=Resource Packs +resourcepack.add=Add Resource Pack +resourcepack.manage=Resource Packs +resourcepack.download=Download Resource Packs +resourcepack.add.failed=Failed to add resource pack +resourcepack.delete.failed=Failed to delete resource pack resourcepack.download.title=Download Resource Pack - %1s reveal.in_file_manager=Reveal in File Manager diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index d01764c5f..8d8d784d9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -993,6 +993,11 @@ repositories.chooser=缺少 JavaFX 執行環境。HMCL 需要 JavaFX 才能正 repositories.chooser.title=選取 JavaFX 下載源 resourcepack=資源包 +resourcepack.add=新增資源包 +resourcepack.manage=資源包管理 +resourcepack.download=下載資源包 +resourcepack.add.failed=新增資源包失敗 +resourcepack.delete.failed=刪除資源包失敗 resourcepack.download.title=資源包下載 - %1s reveal.in_file_manager=在檔案管理員中查看 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 43603a3cb..ca4bdbc64 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1003,6 +1003,11 @@ repositories.chooser=缺少 JavaFX 运行环境。HMCL 需要 JavaFX 才能正 repositories.chooser.title=选择 JavaFX 下载源 resourcepack=资源包 +resourcepack.add=添加资源包 +resourcepack.manage=资源包管理 +resourcepack.download=下载资源包 +resourcepack.add.failed=添加资源包失败 +resourcepack.delete.failed=删除资源包失败 resourcepack.download.title=资源包下载 - %1s reveal.in_file_manager=在文件管理器中查看 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index f87f32799..40784b4bb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -559,4 +559,8 @@ public class DefaultGameRepository implements GameRepository { .append("baseDirectory", baseDirectory) .toString(); } + + public Path getResourcepacksDirectory(String id) { + return getRunDirectory(id).toPath().resolve("resourcepacks"); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java new file mode 100644 index 000000000..072d4d6a9 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java @@ -0,0 +1,37 @@ +package org.jackhuang.hmcl.resourcepack; + +import com.google.gson.JsonParser; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public interface ResourcepackFile { + String getDescription(); + + String getName(); + + File getFile(); + + Path getIcon(); + + default String parseDescriptionFromJson(String json) { + try { + return JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("pack").get("description").getAsString(); + } catch (Exception ignored) { + return ""; + } + + } + + static ResourcepackFile parse(Path path) throws IOException { + if (Files.isRegularFile(path) && path.toString().toLowerCase().endsWith(".zip")) { + return new ResourcepackZipFile(path.toFile()); + } else if (Files.isDirectory(path) && Files.exists(path.resolve("pack.mcmeta"))) { + return new ResourcepackFolder(path.toFile()); + } + return null; + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java new file mode 100644 index 000000000..7d52f4e45 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java @@ -0,0 +1,37 @@ +package org.jackhuang.hmcl.resourcepack; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +public class ResourcepackFolder implements ResourcepackFile { + private final File folder; + + public ResourcepackFolder(File folder) { + this.folder = folder; + } + + @Override + public String getName() { + return folder.getName(); + } + + @Override + public File getFile() { + return folder; + } + + @Override + public String getDescription() { + try { + return parseDescriptionFromJson(Files.readString(folder.toPath().resolve("pack.mcmeta"))); + } catch (Exception ignored) { + return ""; + } + } + + @Override + public Path getIcon() { + return folder.toPath().resolve("pack.png"); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java new file mode 100644 index 000000000..39d787979 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java @@ -0,0 +1,45 @@ +package org.jackhuang.hmcl.resourcepack; + +import org.jackhuang.hmcl.util.io.CompressingUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; + +public class ResourcepackZipFile implements ResourcepackFile { + private final FileSystem zipfs; + private final File resourcepackfile; + + public ResourcepackZipFile(File resourcepackfile) throws IOException { + this.resourcepackfile = resourcepackfile; + this.zipfs = CompressingUtils.createReadOnlyZipFileSystem(resourcepackfile.toPath()); + } + + @Override + public String getName() { + return resourcepackfile.getName().replace(".zip", ""); + } + + @Override + public File getFile() { + return resourcepackfile; + } + + @Override + public String getDescription() { + + try { + return parseDescriptionFromJson(Files.readString(zipfs.getPath("pack.mcmeta"))); + } catch (Exception ignored) { + return ""; + } + } + + @Override + public Path getIcon() { + return zipfs.getPath("pack.png"); + } +} +