diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java index 4a177b0ce..858f3df22 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java @@ -56,29 +56,20 @@ import org.jackhuang.hmcl.ui.construct.FloatListCell; import org.jackhuang.hmcl.ui.construct.SpinnerPane; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.util.AggregatedObservableList; -import org.jackhuang.hmcl.util.Holder; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.i18n.I18n; -import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.javafx.BindingMapping; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNull; -import java.lang.ref.WeakReference; import java.net.URI; import java.util.*; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.stream.Collectors; import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent; import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class DownloadListPage extends Control implements DecoratorPage, VersionPage.VersionLoadable { protected final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); @@ -247,6 +238,12 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP private static class ModDownloadListPageSkin extends SkinBase { private final JFXListView listView = new JFXListView<>(); + private final RemoteImageLoader iconLoader = new RemoteImageLoader() { + @Override + protected @NotNull Task createLoadTask(@NotNull URI uri) { + return FXUtils.getRemoteImageTask(uri, 80, 80, true, true); + } + }; protected ModDownloadListPageSkin(DownloadListPage control) { super(control); @@ -377,6 +374,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0); IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1); EventHandler searchAction = e -> { + iconLoader.clearInvalidCache(); if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) { control.pageOffset.set(0); } @@ -536,7 +534,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP // ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE); - var iconCache = new WeakHashMap>>(); + listView.setCellFactory(x -> new FloatListCell<>(listView) { private final TwoLineListItem content = new TwoLineListItem(); private final ImageView imageView = new ImageView(); @@ -564,66 +562,9 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP if (getSkinnable().shouldDisplayCategory(category)) content.addTag(getSkinnable().getLocalizedCategory(category)); } - loadIcon(dataItem); + iconLoader.load(imageView.imageProperty(), dataItem.getIconUrl()); } - private void loadIcon(RemoteMod mod) { - if (StringUtils.isBlank(mod.getIconUrl())) { - imageView.setImage(null); - return; - } - - WeakReference> cacheRef = iconCache.get(mod.getIconUrl()); - CompletableFuture cache; - if (cacheRef != null && (cache = cacheRef.get()) != null) { - loadIcon(cache, mod.getIconUrl()); - return; - } - - URI iconUrl = NetworkUtils.toURIOrNull(mod.getIconUrl()); - if (iconUrl == null) { - imageView.setImage(null); - return; - } - - CompletableFuture future = new CompletableFuture<>(); - WeakReference> futureRef = new WeakReference<>(future); - iconCache.put(mod.getIconUrl(), futureRef); - - FXUtils.getRemoteImageTask(iconUrl, 80, 80, true, true) - .whenComplete(Schedulers.defaultScheduler(), (result, exception) -> { - if (exception == null) { - future.complete(result); - } else { - LOG.warning("Failed to load image from " + iconUrl, exception); - future.completeExceptionally(exception); - } - }).start(); - loadIcon(future, mod.getIconUrl()); - } - - private void loadIcon(@NotNull CompletableFuture future, - @NotNull String iconUrl) { - Image image; - try { - image = future.getNow(null); - } catch (CancellationException | CompletionException ignored) { - imageView.setImage(null); - return; - } - - if (image != null) { - imageView.setImage(image); - } else { - imageView.setImage(null); - future.thenAcceptAsync(result -> { - RemoteMod item = getItem(); - if (item != null && iconUrl.equals(item.getIconUrl())) { - this.imageView.setImage(result); - } - }, Schedulers.javafx()); - } - } }); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/RemoteImageLoader.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/RemoteImageLoader.java new file mode 100644 index 000000000..7ea84f110 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/RemoteImageLoader.java @@ -0,0 +1,115 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util; + +import javafx.beans.value.WritableValue; +import javafx.scene.image.Image; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.ref.WeakReference; +import java.net.URI; +import java.util.*; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// @author Glavo +public abstract class RemoteImageLoader { + private final Map> cache = new HashMap<>(); + private final Map>>> pendingRequests = new HashMap<>(); + private final WeakHashMap, URI> reverseLookup = new WeakHashMap<>(); + + public RemoteImageLoader() { + } + + protected @Nullable Image getPlaceholder() { + return null; + } + + protected abstract @NotNull Task createLoadTask(@NotNull URI uri); + + @FXThread + public void load(@NotNull WritableValue writableValue, String url) { + URI uri = NetworkUtils.toURIOrNull(url); + if (uri == null) { + reverseLookup.remove(writableValue); + writableValue.setValue(getPlaceholder()); + return; + } + + WeakReference reference = cache.get(uri); + if (reference != null) { + Image image = reference.get(); + if (image != null) { + reverseLookup.remove(writableValue); + writableValue.setValue(image); + return; + } + cache.remove(uri); + } + + { + List>> list = pendingRequests.get(uri); + if (list != null) { + list.add(new WeakReference<>(writableValue)); + reverseLookup.put(writableValue, uri); + return; + } else { + list = new ArrayList<>(1); + list.add(new WeakReference<>(writableValue)); + pendingRequests.put(uri, list); + reverseLookup.put(writableValue, uri); + } + } + + createLoadTask(uri).whenComplete(Schedulers.javafx(), (result, exception) -> { + Image image; + if (exception == null) { + image = result; + } else { + LOG.warning("Failed to load image from " + uri, exception); + image = getPlaceholder(); + } + + cache.put(uri, new WeakReference<>(image)); + List>> list = pendingRequests.remove(uri); + if (list != null) { + for (WeakReference> ref : list) { + WritableValue target = ref.get(); + if (target != null && uri.equals(reverseLookup.get(target))) { + reverseLookup.remove(target); + target.setValue(image); + } + } + } + }).start(); + } + + @FXThread + public void unload(@NotNull WritableValue writableValue) { + reverseLookup.remove(writableValue); + } + + @FXThread + public void clearInvalidCache() { + cache.entrySet().removeIf(entry -> entry.getValue().get() == null); + } +}