Skip to content
249 changes: 249 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/setting/FavoritesManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.setting;

import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;

import static org.jackhuang.hmcl.util.logging.Logger.LOG;

public final class FavoritesManager {

private static final TypeToken<TreeMap<String, LinkedHashSet<Item>>> typeToken = new TypeToken<>() {
};

private static final FavoritesManager instance = new FavoritesManager(Metadata.HMCL_USER_HOME.resolve("config").resolve("user-addon-favorites.json"));

public static FavoritesManager getInstance() {
return instance;
}

private final Path file; // Any external changes to this file while the application is running might be lost
private final TreeMap<String, Favorites> favoritesMap = new TreeMap<>(String::compareToIgnoreCase);
private final ReentrantLock lock = new ReentrantLock();
private boolean loaded;

private FavoritesManager(Path favoritesFile) {
this.file = Objects.requireNonNull(favoritesFile);
}

public Path getFile() {
return file;
}

public void refresh() {
lock.lock();
try {
loaded = false;
load();
} finally {
lock.unlock();
}
}

public void load() {
if (loaded) return;
lock.lock();
try {
if (loaded) return;
favoritesMap.clear();
var map = Files.isRegularFile(file) ? JsonUtils.fromJsonFile(file, typeToken) : null;
if (map != null)
map.forEach((name, items) -> favoritesMap.put(name, new Favorites(this, name, items)));
loaded = true;
} catch (IOException | JsonSyntaxException e) {
LOG.warning("Failed to load favorites file at " + file, e);
} finally {
lock.unlock();
}
}

public void save() {
lock.lock();
try {
var pairs = favoritesMap.entrySet().stream().map(entry -> Pair.pair(entry.getKey(), entry.getValue().items)).toList();
JsonUtils.writeToJsonFile(file, Lang.mapOf(pairs));
} catch (IOException e) {
LOG.warning("Failed to save favorites file at " + file, e);
} finally {
lock.unlock();
}
}

public void resolveAll(DownloadProvider downloadProvider) {
lock.lock();
try {
favoritesMap.values().forEach(fav -> fav.resolve0(downloadProvider));
} finally {
lock.unlock();
}
}

@Unmodifiable
public List<Favorites> getFavorites() {
lock.lock();
try {
if (!loaded) throw new IllegalStateException("Favorites not loaded");
return List.copyOf(favoritesMap.values());
} finally {
lock.unlock();
}
}

@NotNull
public Favorites getOrCreate(String name) {
lock.lock();
try {
return favoritesMap.computeIfAbsent(name, n -> new Favorites(this, n, new LinkedHashSet<>()));
} finally {
lock.unlock();
}
}

public static final class Favorites {

private final FavoritesManager manager;
private final String name;

private final LinkedHashSet<Item> items;
private transient final ArrayList<RemoteAddon> resolvedAddons = new ArrayList<>();
private transient final HashMap<Item, RemoteAddon> cache = new HashMap<>();
private transient DownloadProvider lastProvider = null;

private final ReentrantLock lock;

private Favorites(FavoritesManager manager, String name, LinkedHashSet<Item> items) {
this.manager = manager;
this.lock = manager.lock;
this.name = name;
this.items = new LinkedHashSet<>(items);
}

public String getName() {
return name;
}

@Unmodifiable
public Set<Item> getItems() {
lock.lock();
try {
return Set.copyOf(items);
} finally {
lock.unlock();
}
}

@Unmodifiable
public List<RemoteAddon> getResolvedAddons() {
lock.lock();
try {
return List.copyOf(resolvedAddons);
} finally {
lock.unlock();
}
}

private void resolve0(DownloadProvider downloadProvider) {
resolvedAddons.clear();
if (downloadProvider != lastProvider) {
cache.clear();
lastProvider = downloadProvider;
}
List<RemoteAddon> result = new ArrayList<>(items.size());
for (var item : items) {
RemoteAddon addon;
if (cache.containsKey(item)) {
addon = cache.get(item);
} else {
try {
addon = item.resolve(downloadProvider);
} catch (IOException e) {
LOG.warning("Failed to resolve favorite item: " + item, e);
continue;
}
}
cache.put(item, addon);
result.add(addon);
}
resolvedAddons.addAll(result);
}

public void resolve(DownloadProvider downloadProvider) {
lock.lock();
try {
resolve0(downloadProvider);
} finally {
lock.unlock();
}
}

public void add(RemoteAddon addon) {
lock.lock();
try {
var item = Item.fromAddon(addon);
items.remove(item);
items.add(item);
manager.save();
} finally {
lock.unlock();
}
}

public void remove(Collection<RemoteAddon> addons) {
lock.lock();
try {
if (items.removeAll(addons.stream().map(Item::fromAddon).collect(Collectors.toSet())))
manager.save();
} finally {
lock.unlock();
}
}

}

@JsonSerializable
public record Item(@Nullable String projectId, @Nullable RemoteAddon.Source source) {

public static Item fromAddon(RemoteAddon addon) {
return new Item(addon.projectId(), addon.source());
}

public @NotNull RemoteAddon resolve(DownloadProvider downloadProvider) throws IOException {
if (projectId == null || source == null) return RemoteAddon.BROKEN;
return source.getCommonRepo().getAddonById(downloadProvider, projectId);
}

}
}
20 changes: 10 additions & 10 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,10 @@
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.TabHeader;
import org.jackhuang.hmcl.ui.construct.Validator;
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.instances.DownloadListPage;
import org.jackhuang.hmcl.ui.instances.HMCLLocalizedDownloadListPage;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.instances.*;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
Expand Down Expand Up @@ -84,6 +78,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
private final TabHeader.Tab<DownloadListPage> resourcePackTab = new TabHeader.Tab<>("resourcePackTab");
private final TabHeader.Tab<DownloadListPage> shaderTab = new TabHeader.Tab<>("shaderTab");
private final TabHeader.Tab<DownloadListPage> worldTab = new TabHeader.Tab<>("worldTab");
private final TabHeader.Tab<AddonFavoritesPage> favoritesPageTab = new TabHeader.Tab<>("favoritesTab");
private final TransitionPane transitionPane = new TransitionPane();
private final DownloadNavigator versionPageNavigator = new DownloadNavigator();

Expand Down Expand Up @@ -111,7 +106,8 @@ public DownloadPage(GameInstanceID uploadInstance) {
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true)));
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteAddonRepository.WORLDS)));
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab);
favoritesPageTab.setNodeSupplier(loadVersionFor(AddonFavoritesPage::new));
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab, favoritesPageTab);

GameDirectoryManager.registerVersionsListener(this::loadVersions);

Expand All @@ -125,7 +121,8 @@ public DownloadPage(GameInstanceID uploadInstance) {
.addNavigationDrawerTab(tab, modTab, i18n("mods"), SVG.EXTENSION, SVG.EXTENSION_FILL)
.addNavigationDrawerTab(tab, resourcePackTab, i18n("resourcepack"), SVG.TEXTURE)
.addNavigationDrawerTab(tab, shaderTab, i18n("download.shader"), SVG.WB_SUNNY, SVG.WB_SUNNY_FILL)
.addNavigationDrawerTab(tab, worldTab, i18n("world"), SVG.PUBLIC);
.addNavigationDrawerTab(tab, worldTab, i18n("world"), SVG.PUBLIC)
.addNavigationDrawerTab(tab, favoritesPageTab, i18n("addon.favorites"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL);
FXUtils.setLimitWidth(sideBar, 200);
setLeft(sideBar);

Expand Down Expand Up @@ -205,6 +202,9 @@ private void loadVersions(HMCLGameRepository repository) {
if (worldTab.isInitialized()) {
worldTab.getNode().loadInstance(repository, null);
}
if (favoritesPageTab.isInitialized()) {
favoritesPageTab.getNode().loadInstance(repository, null);
}
}));
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.instances;

import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.Control;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.FavoritesManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jetbrains.annotations.Nullable;

public class AddonFavoritesPage extends Control implements DecoratorPage, GameInstancePage.GameInstanceLoadable {

private static final FavoritesManager manager = FavoritesManager.getInstance();

protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loading = new SimpleBooleanProperty(false);
private final ObjectProperty<HMCLGameRepository.InstanceReference> instanceReference = new SimpleObjectProperty<>();
private final ObservableList<GameInstanceID> instances = FXCollections.observableArrayList();
private final ObjectProperty<GameInstanceID> selectedInstance = new SimpleObjectProperty<>();

private final ListProperty<FavoritesManager.Favorites> items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());

@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}

@Override
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId));
instances.setAll(repository.getDisplayInstanceManifests()
.map(GameInstanceManifest::id)
.toList());
selectedInstance.set(repository.getSelectedInstance());
refresh();
}

public void refresh() {
setLoading(true);
Task.runAsync(Schedulers.io(), manager::load)
.thenRunAsync(Schedulers.javafx(), () -> {
items.setAll(manager.getFavorites());
}).start();
}

public void setLoading(boolean loading) {
this.loading.set(loading);
}
}
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ addon.download.recommend=Recommended Version for Minecraft %1s
addon.download.title.release=Minecraft %s
addon.download.title.snapshot=Minecraft %s (Snapshots)
addon.modrinth=Modrinth
addon.favorites=Favorites

archive.author=Author(s)
archive.date=Publish Date
Expand Down
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ addon.download.recommend=推薦版本 - Minecraft %1s
addon.download.title.release=Minecraft %s
addon.download.title.snapshot=Minecraft %s (快照)
addon.modrinth=Modrinth
addon.favorites=我的最愛

archive.author=作者
archive.date=發布日期
Expand Down
Loading