diff --git a/core/src/bms/player/beatoraja/MainController.java b/core/src/bms/player/beatoraja/MainController.java index e4f948f86..7e916c1de 100644 --- a/core/src/bms/player/beatoraja/MainController.java +++ b/core/src/bms/player/beatoraja/MainController.java @@ -7,6 +7,7 @@ import bms.player.beatoraja.exceptions.PlayerConfigException; import bms.player.beatoraja.modmenu.ImGuiRenderer; +import bms.player.beatoraja.modmenu.fm.SongManagerMenu; import com.badlogic.gdx.*; import com.badlogic.gdx.graphics.*; import com.badlogic.gdx.graphics.g2d.*; @@ -355,6 +356,7 @@ public void create() { streamController = new StreamController(selector, (player.getRequestNotify() ? messageRenderer : null)); streamController.run(); } + SongManagerMenu.injectMusicSelector(selector); decide = new MusicDecide(this); result = new MusicResult(this); gresult = new CourseResult(this); diff --git a/core/src/bms/player/beatoraja/modmenu/ImGuiRenderer.java b/core/src/bms/player/beatoraja/modmenu/ImGuiRenderer.java index ae460c664..6e88fec7e 100644 --- a/core/src/bms/player/beatoraja/modmenu/ImGuiRenderer.java +++ b/core/src/bms/player/beatoraja/modmenu/ImGuiRenderer.java @@ -1,7 +1,10 @@ package bms.player.beatoraja.modmenu; import bms.player.beatoraja.controller.Lwjgl3ControllerManager; +import bms.player.beatoraja.modmenu.fm.SongManagerMenu; +import bms.player.beatoraja.modmenu.fm.FolderManagerMenu; +import bms.player.beatoraja.select.MusicSelector; import com.badlogic.gdx.Gdx; import com.badlogic.gdx.InputProcessor; import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Graphics; @@ -35,8 +38,9 @@ public class ImGuiRenderer { private static ImBoolean SHOW_MOD_MENU = new ImBoolean(false); private static ImBoolean SHOW_RANDOM_TRAINER = new ImBoolean(false); - private static ImBoolean SHOW_FREQ_PLUS = new ImBoolean(false); + private static ImBoolean SHOW_FOLDER_MANAGER = new ImBoolean(false); + private static ImBoolean SHOW_SONG_MANAGER = new ImBoolean(false); public static void init() { @@ -77,7 +81,7 @@ public static void init() { public static void start() { if (tmpProcessor != null) { Gdx.input.setInputProcessor(tmpProcessor); - tmpProcessor = null; + tmpProcessor = null; } imGuiGlfw.newFrame(); ImGui.newFrame(); @@ -94,13 +98,21 @@ public static void render() { ImGui.checkbox("Show Rate Modifier Window", SHOW_FREQ_PLUS); ImGui.checkbox("Show Random Trainer Window", SHOW_RANDOM_TRAINER); + ImGui.checkbox("Show Folder Manager Menu", SHOW_FOLDER_MANAGER); + ImGui.checkbox("Show Song Manager Menu", SHOW_SONG_MANAGER); + if (SHOW_FREQ_PLUS.get()) { FreqTrainerMenu.show(SHOW_FREQ_PLUS); } if (SHOW_RANDOM_TRAINER.get()) { RandomTrainerMenu.show(SHOW_RANDOM_TRAINER); } - + if (SHOW_FOLDER_MANAGER.get()) { + FolderManagerMenu.show(SHOW_FOLDER_MANAGER); + } + if (SHOW_SONG_MANAGER.get()) { + SongManagerMenu.show(SHOW_SONG_MANAGER); + } if (ImGui.treeNode("Controller Input Debug Information")) { float axis; @@ -149,7 +161,6 @@ public static void helpMarker(String desc) { ImGui.popTextWrapPos(); ImGui.endTooltip(); } - } diff --git a/core/src/bms/player/beatoraja/modmenu/fm/FolderDefinition.java b/core/src/bms/player/beatoraja/modmenu/fm/FolderDefinition.java new file mode 100644 index 000000000..9b98cce4e --- /dev/null +++ b/core/src/bms/player/beatoraja/modmenu/fm/FolderDefinition.java @@ -0,0 +1,98 @@ +package bms.player.beatoraja.modmenu.fm; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/* + * Represents one folder definition in folder/default.json + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class FolderDefinition { + private String sql; + private String name; + @JsonProperty("showall") + private Boolean showAll; + @JsonProperty("folder") + private List children; + + // NOTE: This field can be viewed as key + @JsonIgnore + private Integer bits; + + public FolderDefinition(String sql, String name, Boolean showAll, Integer bits) { + this.sql = sql; + this.name = name; + this.showAll = showAll; + this.bits = bits; + } + + public FolderDefinition(String sql, String name, Boolean showAll) { + this(sql, name, showAll, null); + } + + public FolderDefinition() { + this(null, null, null, null); + } + + /** + * If sql field satisfy 'favorite & x != 0' pattern, set corresponding bits field + * Otherwise do nothing + * + * @implSpec Do not throw error even if sql field is corrupted or cannot take bits from it + */ + public void tryExtractBitsFromSql() { + if (sql == null || sql.isEmpty()) { + return ; + } + String left = "favorite & "; + String right = " != 0"; + if (!sql.startsWith(left) || !sql.endsWith(right)) { + return ; + } + String expectedStr = sql.substring(left.length(), sql.length() - right.length()); + if (expectedStr.isEmpty()) { + return ; + } + try { + int pw = Integer.parseInt(expectedStr); + this.bits = 31 - Integer.numberOfLeadingZeros(pw); + } catch (Exception e) { + // Do nothing + } + } + + public String getSql() { + return sql; + } + + public String getName() { + return name; + } + + public Boolean getShowAll() { + return showAll; + } + + public Integer getBits() { + return bits; + } + + public void setSql(String sql) { + this.sql = sql; + } + + public void setName(String name) { + this.name = name; + } + + public void setShowAll(Boolean showAll) { + this.showAll = showAll; + } + + public void setBits(Integer bits) { + this.bits = bits; + } +} diff --git a/core/src/bms/player/beatoraja/modmenu/fm/FolderManager.java b/core/src/bms/player/beatoraja/modmenu/fm/FolderManager.java new file mode 100644 index 000000000..f9ab89e8a --- /dev/null +++ b/core/src/bms/player/beatoraja/modmenu/fm/FolderManager.java @@ -0,0 +1,155 @@ +package bms.player.beatoraja.modmenu.fm; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +/** + * NOTE: FolderManager can only manage folders which sql is based on favorite field + * Expect some low-level functions, you should never use FOLDER_DEFINITIONS directly + * or try to modify the result from getFolderDefinitions() + */ +public class FolderManager { + private static final String FILE_LOCATION = "folder/default.json"; + private static List FOLDER_DEFINITIONS = new ArrayList<>(); + + //@formatter:off + static { + try { + ObjectMapper om = new ObjectMapper(); + List fds = om.readValue(new BufferedInputStream(Files.newInputStream(Paths.get(FILE_LOCATION))), new TypeReference<>(){}); + // Hack: extract the bits field from sql for not breaking the compatibility + fds.forEach(fd -> { + fd.tryExtractBitsFromSql(); + FOLDER_DEFINITIONS.add(fd); + }); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + //@formatter:on + + // NOTE: Here's a limitation from 'favorite' length (26+4=30 <= 31) + private static final int MAXIMUM_FOLDER_COUNT = 5; + + public static List getFolderDefinitions() { + return FOLDER_DEFINITIONS.stream().filter(fd -> fd.getBits() != null).toList(); + } + + /** + * Persist fds to folder/default.json + */ + public static void persist(List fds) throws IOException { + ObjectMapper om = new ObjectMapper(); + om.writeValue(new BufferedOutputStream(Files.newOutputStream(Paths.get(FILE_LOCATION))), fds); + } + + /** + * Save one folder definition
+ *
  • Update FOLDER_DEFINITIONS
  • + *
  • Persist current data to disk
  • + * + * @implSpec If persist failed, FOLDER_DEFINITIONS must keep the same + */ + private static void save(FolderDefinition folderDefinition) throws IOException { + try { + // Copy entire list for persisting + List copy = new ArrayList<>(FOLDER_DEFINITIONS); + copy.add(folderDefinition); + persist(copy); + } catch (Exception e) { + e.printStackTrace(); + throw e; + } + FOLDER_DEFINITIONS.add(folderDefinition); + } + + private static void remove(int bits) throws IOException { + Predicate equalsOnBits = fd -> fd.getBits() != null && fd.getBits().equals(bits); + try { + List copy = new ArrayList<>(FOLDER_DEFINITIONS); + if (!copy.removeIf(equalsOnBits)) { + return ; // Okay dokey + } + persist(copy); + } catch (Exception e) { + e.printStackTrace(); + throw e; + } + FOLDER_DEFINITIONS.removeIf(equalsOnBits); + } + + /** + * Create a new folder and persist state to disk + * + * @param name folder name + * @throws IllegalStateException if there is no room for folder or something bad happens + */ + public static void newFolder(String name) throws IllegalStateException { + if (getFolderDefinitions().size() >= MAXIMUM_FOLDER_COUNT) { + throw new IllegalStateException("Failed to save changes: folder count cannot exceed to " + MAXIMUM_FOLDER_COUNT); + } + List usedBits = getFolderDefinitions().stream().map(FolderDefinition::getBits).toList(); + int nextBit = findMex(usedBits); + FolderDefinition fd = new FolderDefinition(generateSql(nextBit), name, false, nextBit); + try { + save(fd); + } catch (Exception e) { + throw new IllegalStateException("Failed to save changes: " + e.getMessage()); + } + } + + /** + * Remove specified folder and persist current state to disk + * + * @throws IllegalStateException if the folder is not exist or something bad happens + */ + public static void removeFolder(int bits) throws IllegalStateException { + try { + remove(bits); + } catch (Exception e) { + throw new IllegalStateException("Failed to save changes: " + e.getMessage()); + } + } + + /** + * Get the mex from an array of integers + * MEX: The minimum integer x satisfy "x >= 0 and x doesn't appear in arr" + * + * @return MEX of arr + */ + private static int findMex(List arr) { + int mex = 0; + // No need for sort + while (true) { + boolean noProgress = true; + for (Integer x : arr) { + if (x.equals(mex)) { + noProgress = false; + mex++; + break; + } + } + if (noProgress) { + break; + } + } + return mex; + } + + /** + * @return `favorite & {bits} != 0` + */ + private static String generateSql(Integer bits) { + return String.format("favorite & %d != 0", 1 << bits); + } +} diff --git a/core/src/bms/player/beatoraja/modmenu/fm/FolderManagerMenu.java b/core/src/bms/player/beatoraja/modmenu/fm/FolderManagerMenu.java new file mode 100644 index 000000000..bf4332a77 --- /dev/null +++ b/core/src/bms/player/beatoraja/modmenu/fm/FolderManagerMenu.java @@ -0,0 +1,100 @@ +package bms.player.beatoraja.modmenu.fm; + +import imgui.ImGui; +import imgui.flag.ImGuiCond; +import imgui.flag.ImGuiWindowFlags; +import imgui.type.ImBoolean; +import imgui.type.ImString; + +import java.util.List; + +import static bms.player.beatoraja.modmenu.ImGuiRenderer.windowHeight; +import static bms.player.beatoraja.modmenu.ImGuiRenderer.windowWidth; + +public class FolderManagerMenu { + private static ImString inputName = new ImString(); + private static ImString errorText = new ImString(); + private static ImBoolean showErrorPopup = new ImBoolean(false); + + public static void show(ImBoolean showFolderManager) { + float relativeX = windowWidth * 0.455f; + float relativeY = windowHeight * 0.04f; + ImGui.setNextWindowPos(relativeX, relativeY, ImGuiCond.FirstUseEver); + + if (ImGui.begin("Folder Manager", showFolderManager, ImGuiWindowFlags.AlwaysAutoResize)) { + // Render misc buttons + if (ImGui.button("New Folder")) { + ImGui.openPopup("Create a new folder"); + } + if (ImGui.beginPopupModal("Create a new folder", ImGuiWindowFlags.AlwaysAutoResize)) { + ImGui.inputText("name", inputName); + if (ImGui.button("OK")) { + try { + FolderManager.newFolder(inputName.get()); + } catch (IllegalStateException e) { + // Popup is handled as a stack, which means we cannot directly open an + // error popup here and expect it 'replace' the current popup + errorText.set(e.getMessage()); + showErrorPopup.set(true); + } + inputName.clear(); + ImGui.closeCurrentPopup(); + } + ImGui.sameLine(); + if (ImGui.button("Cancel")) { + inputName.clear(); + ImGui.closeCurrentPopup(); + } + + ImGui.endPopup(); + } + + if (showErrorPopup.get()) { + ImGui.openPopup("Error"); + showErrorPopup.set(false); + } + + // Render error popup + if (ImGui.beginPopupModal("Error", ImGuiWindowFlags.AlwaysAutoResize)) { + ImGui.text(errorText.get()); + if (ImGui.button("OK")) { + ImGui.closeCurrentPopup(); + } + ImGui.endPopup(); + } + + // Render folders + List folderDefinitions = FolderManager.getFolderDefinitions(); + for (int i = 0;i < folderDefinitions.size();++i) { + FolderDefinition folderDefinition = folderDefinitions.get(i); + ImGui.pushID(i); + float spacing = ImGui.getStyle().getItemInnerSpacingX(); + ImGui.alignTextToFramePadding(); + ImGui.bulletText(folderDefinition.getName()); + ImGui.sameLine(0.0f, spacing); + if (ImGui.button("E")) { + + } + ImGui.sameLine(0.0f, spacing); + if (ImGui.button("X")) { + ImGui.openPopup("Delete?"); + } + if (ImGui.beginPopupModal("Delete?", ImGuiWindowFlags.AlwaysAutoResize)) { + ImGui.text("Do you really want to delete this folder?All related data would be deleted directly and can never be restored!"); + if (ImGui.button("Yes")) { + FolderManager.removeFolder(folderDefinition.getBits()); + ImGui.closeCurrentPopup(); + } + if (ImGui.button("Cancel")) { + ImGui.closeCurrentPopup(); + } + ImGui.endPopup(); + } + ImGui.newLine(); + ImGui.popID(); + } + } + + ImGui.end(); + } +} diff --git a/core/src/bms/player/beatoraja/modmenu/fm/SongManagerMenu.java b/core/src/bms/player/beatoraja/modmenu/fm/SongManagerMenu.java new file mode 100644 index 000000000..cd14cd82b --- /dev/null +++ b/core/src/bms/player/beatoraja/modmenu/fm/SongManagerMenu.java @@ -0,0 +1,159 @@ +package bms.player.beatoraja.modmenu.fm; + +import bms.player.beatoraja.SystemSoundManager; +import bms.player.beatoraja.select.MusicSelector; +import bms.player.beatoraja.select.bar.SongBar; +import bms.player.beatoraja.song.SongData; +import imgui.ImGui; +import imgui.flag.ImGuiWindowFlags; +import imgui.type.ImBoolean; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; + + +public class SongManagerMenu { + // I cannot think of a better solution than hold a ref of MusicSelector + private static MusicSelector selector; + /** + * Reflect current song state, would be updated at the begin of the show function
    + * It's very dangerous to keep opening the `Song Manager Menu` while editing the folder definitions + */ + private static List folderMarkList = new ArrayList<>(); + /** + * Reflect current editing state
    + * Workaround since we cannot freeze the game main thread + */ + private static List currentMarkList = new ArrayList<>(); + /** + * Last selected song data ref
    + * Used to detect if player changed current selected song + */ + private static Optional lastSongData = Optional.empty(); + + public static void show(ImBoolean showSongManager) { + List fdSnapshot = FolderManager.getFolderDefinitions(); + Optional currentSongData = getCurrentSongData(); + updateMarkList(currentSongData, fdSnapshot); + if (ImGui.begin("Song Manager", showSongManager, ImGuiWindowFlags.AlwaysAutoResize)) { + int favorite = currentSongData.map(SongData::getFavorite).orElse(0); + String songName = currentSongData.map(SongData::getTitle).orElse(""); + ImGui.text("current picking: " + songName); + ImGui.text("Debug: favorite: " + favorite); + if (songName.isEmpty()) { + ImGui.text("Not a selectable song"); + } else { + if (favorite == 0) { + ImGui.text("No related folder"); + } else { + for (int i = 0; i < fdSnapshot.size(); ++i) { + FolderDefinition fd = fdSnapshot.get(i); + if ((favorite & (1 << fd.getBits())) != 0) { + ImGui.pushID(i); + ImGui.bulletText(fd.getName()); + ImGui.popID(); + } + } + } + if (ImGui.button("Add to folders")) { + ImGui.openPopup("Add song to folder popup"); + } + if (ImGui.beginPopupModal("Add song to folder popup", ImGuiWindowFlags.AlwaysAutoResize)) { + for (int i = 0;i < fdSnapshot.size();++i) { + FolderDefinition fd = fdSnapshot.get(i); + ImGui.checkbox(fd.getName(), currentMarkList.get(i)); + } + if (ImGui.button("OK")) { + Logger.getGlobal().info("WE ARE ACTUALLY ARRIVED"); + List bindsTo = new ArrayList<>(); + for (int i = 0;i < fdSnapshot.size();++i) { + if (currentMarkList.get(i).get()) { + bindsTo.add(fdSnapshot.get(i)); + } + } + if (!bindsTo.isEmpty()) { + bind(currentSongData.get(), bindsTo, false); + } + ImGui.closeCurrentPopup(); + } + if (ImGui.button("Cancel")) { + ImGui.closeCurrentPopup(); + } + ImGui.endPopup(); + } + } + + } + ImGui.end(); + } + + public static void injectMusicSelector(MusicSelector musicSelector) { + selector = musicSelector; + } + + /** + * Update mark list by current song data & folder definitions + * + * @param currentSongData do nothing if empty + * @param snapshot current folder definitions' snapshot + */ + private static void updateMarkList(Optional currentSongData, List snapshot) { + if (currentSongData.isEmpty()) { + return ; + } + if (lastSongData.isPresent()) { + String lastPath = lastSongData.get().getPath(); + String lastSha256 = lastSongData.get().getSha256(); + String currentPath = currentSongData.get().getPath(); + String currentSha256 = currentSongData.get().getSha256(); + if (lastPath.equals(currentPath) && lastSha256.equals(currentSha256)) { + return ;// Okay dokey + } + } + lastSongData = currentSongData; + int favorite = currentSongData.get().getFavorite(); + + folderMarkList = snapshot.stream() + .map(fd -> (favorite & (1 << fd.getBits())) != 0) + .map(ImBoolean::new) + .toList(); + currentMarkList = new ArrayList<>(folderMarkList); + } + + private static Optional getCurrentSongData() { + if (selector.getSelectedBar() instanceof SongBar) { + final SongData sd = ((SongBar) selector.getSelectedBar()).getSongData(); + if (sd != null && sd.getPath() != null) { + return Optional.of(sd); + } + } + return Optional.empty(); + } + + /** + * @param fds The *FULL SET* of the folders to bind + * @param addAll add all difficult to selected folder if true, otherwise only add current picked one + */ + private static void bind(SongData songData, List fds, boolean addAll) { + if (fds == null || fds.isEmpty()) { + return ; // Okay dokey + } + int favorite = songData.getFavorite(); + for (FolderDefinition fd : fds) { + favorite |= (1 << fd.getBits()); + } + SongData[] songs = selector.main.getSongDatabase().getSongDatas("folder", songData.getFolder()); + for (SongData song : songs) { + if (addAll) { + song.setFavorite(song.getFavorite() | favorite); + } else if (song.getSha256().equals(songData.getSha256())) { + song.setFavorite(song.getFavorite() | favorite); + } + } + selector.main.getSongDatabase().setSongDatas(songs); + selector.getBarManager().updateBar(); + selector.play(SystemSoundManager.SoundType.OPTION_CHANGE); + } +}