diff --git a/README.md b/README.md index 25c83756..2827413d 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,10 @@ The Java Runtime Environment (JRE) must be installed in order to run opsu!. The download page is located [here](https://www.java.com/en/download/). ### Beatmaps -opsu! also requires beatmaps to run, which are available for download on the +opsu! requires beatmaps to run, which are available for download on the [osu! website](https://osu.ppy.sh/p/beatmaplist) and mirror sites such as [osu!Mirror](https://osu.yas-online.net/) or [Bloodcat](http://bloodcat.com/osu/). +Beatmaps can also be downloaded directly through opsu! in the downloads menu. If osu! is already installed, this application will attempt to load songs directly from the osu! program folder. Otherwise, place songs in the generated diff --git a/pom.xml b/pom.xml index 8641dcd4..f0c22637 100644 --- a/pom.xml +++ b/pom.xml @@ -138,5 +138,10 @@ sqlite-jdbc 3.8.7 + + org.json + json + 20140107 + \ No newline at end of file diff --git a/res/delete.png b/res/delete.png new file mode 100644 index 00000000..6c10e115 Binary files /dev/null and b/res/delete.png differ diff --git a/res/downloads.png b/res/downloads.png new file mode 100644 index 00000000..37bc29f5 Binary files /dev/null and b/res/downloads.png differ diff --git a/res/search-background.jpg b/res/search-background.jpg new file mode 100644 index 00000000..aee382af Binary files /dev/null and b/res/search-background.jpg differ diff --git a/src/itdelatrisu/opsu/Container.java b/src/itdelatrisu/opsu/Container.java index 68a1709e..0ba30ca9 100644 --- a/src/itdelatrisu/opsu/Container.java +++ b/src/itdelatrisu/opsu/Container.java @@ -19,6 +19,7 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.audio.MusicController; +import itdelatrisu.opsu.downloads.DownloadList; import org.newdawn.slick.AppGameContainer; import org.newdawn.slick.Game; @@ -110,4 +111,13 @@ public class Container extends AppGameContainer { throw e; // re-throw exception } } + + @Override + public void exit() { + // show confirmation dialog if any downloads are active + if (DownloadList.get().hasActiveDownloads() && DownloadList.showExitConfirmation()) + return; + + super.exit(); + } } diff --git a/src/itdelatrisu/opsu/GameImage.java b/src/itdelatrisu/opsu/GameImage.java index 2ffc139e..2e90a4c1 100644 --- a/src/itdelatrisu/opsu/GameImage.java +++ b/src/itdelatrisu/opsu/GameImage.java @@ -465,6 +465,26 @@ public enum GameImage { return img.getScaledCopy((h * 0.15f) / img.getHeight()); } }, + DOWNLOADS ("downloads", "png", false, false) { + @Override + protected Image process_sub(Image img, int w, int h) { + return img.getScaledCopy((h * 0.45f) / img.getHeight()); + } + }, + SEARCH_BG ("search-background", "png|jpg", false, true) { + @Override + protected Image process_sub(Image img, int w, int h) { + img.setAlpha(0.8f); + return img.getScaledCopy(w, h); + } + }, + DELETE ("delete", "png", false, false) { + @Override + protected Image process_sub(Image img, int w, int h) { + int lineHeight = Utils.FONT_DEFAULT.getLineHeight(); + return img.getScaledCopy(lineHeight, lineHeight); + } + }, HISTORY ("history", "png", false, false) { @Override protected Image process_sub(Image img, int w, int h) { @@ -573,10 +593,7 @@ public enum GameImage { * @param type the file types (separated by '|') */ GameImage(String filename, String type) { - this.filename = filename; - this.type = getType(type); - this.skinnable = true; - this.preload = true; + this(filename, type, true, true); } /** @@ -586,11 +603,8 @@ public enum GameImage { * @param type the file types (separated by '|') */ GameImage(String filename, String filenameFormat, String type) { - this.filename = filename; + this(filename, type, true, true); this.filenameFormat = filenameFormat; - this.type = getType(type); - this.skinnable = true; - this.preload = true; } /** diff --git a/src/itdelatrisu/opsu/MenuButton.java b/src/itdelatrisu/opsu/MenuButton.java index fecf0338..50778c90 100644 --- a/src/itdelatrisu/opsu/MenuButton.java +++ b/src/itdelatrisu/opsu/MenuButton.java @@ -50,7 +50,7 @@ public class MenuButton { private Expand dir = Expand.CENTER; /** Scaled expansion directions (for hovering). */ - public enum Expand { CENTER, UP_RIGHT, UP_LEFT, DOWN_RIGHT, DOWN_LEFT; } + public enum Expand { CENTER, UP, RIGHT, LEFT, DOWN, UP_RIGHT, UP_LEFT, DOWN_RIGHT, DOWN_LEFT; } /** * Creates a new button from an Image. @@ -137,7 +137,7 @@ public class MenuButton { public void draw() { if (img != null) { if (imgL == null) { - Image imgScaled = img.getScaledCopy(scale); + Image imgScaled = (scale == 1f) ? img : img.getScaledCopy(scale); imgScaled.setAlpha(img.getAlpha()); imgScaled.draw(x - xRadius, y - yRadius); } else { @@ -229,9 +229,13 @@ public class MenuButton { // offset by difference between normal/scaled image dimensions xOffset = (int) ((scale - 1f) * img.getWidth()); yOffset = (int) ((scale - 1f) * img.getHeight()); - if (dir == Expand.DOWN_RIGHT || dir == Expand.UP_RIGHT) + if (dir == Expand.UP || dir == Expand.DOWN) + xOffset = 0; // no horizontal offset + if (dir == Expand.RIGHT || dir == Expand.LEFT) + yOffset = 0; // no vertical offset + if (dir == Expand.RIGHT || dir == Expand.DOWN_RIGHT || dir == Expand.UP_RIGHT) xOffset *= -1; // flip x for right - if (dir == Expand.DOWN_LEFT || dir == Expand.DOWN_RIGHT) + if (dir == Expand.DOWN || dir == Expand.DOWN_LEFT || dir == Expand.DOWN_RIGHT) yOffset *= -1; // flip y for down } this.xRadius = ((img.getWidth() * scale) + xOffset) / 2f; diff --git a/src/itdelatrisu/opsu/Opsu.java b/src/itdelatrisu/opsu/Opsu.java index 0220f9aa..5cb28073 100644 --- a/src/itdelatrisu/opsu/Opsu.java +++ b/src/itdelatrisu/opsu/Opsu.java @@ -19,6 +19,8 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.audio.MusicController; +import itdelatrisu.opsu.downloads.DownloadList; +import itdelatrisu.opsu.states.DownloadsMenu; import itdelatrisu.opsu.states.Game; import itdelatrisu.opsu.states.GamePauseMenu; import itdelatrisu.opsu.states.GameRanking; @@ -62,7 +64,8 @@ public class Opsu extends StateBasedGame { STATE_GAME = 4, STATE_GAMEPAUSEMENU = 5, STATE_GAMERANKING = 6, - STATE_OPTIONSMENU = 7; + STATE_OPTIONSMENU = 7, + STATE_DOWNLOADSMENU = 8; /** Server socket for restricting the program to a single instance. */ private static ServerSocket SERVER_SOCKET; @@ -85,6 +88,7 @@ public class Opsu extends StateBasedGame { addState(new GamePauseMenu(STATE_GAMEPAUSEMENU)); addState(new GameRanking(STATE_GAMERANKING)); addState(new OptionsMenu(STATE_OPTIONSMENU)); + addState(new DownloadsMenu(STATE_DOWNLOADSMENU)); } /** @@ -194,6 +198,10 @@ public class Opsu extends StateBasedGame { return false; } + // show confirmation dialog if any downloads are active + if (DownloadList.get().hasActiveDownloads() && DownloadList.showExitConfirmation()) + return false; + return true; } @@ -204,6 +212,9 @@ public class Opsu extends StateBasedGame { // close scores database ScoreDB.closeConnection(); + // cancel all downloads + DownloadList.get().cancelAllDownloads(); + // close server socket if (SERVER_SOCKET != null) { try { diff --git a/src/itdelatrisu/opsu/OsuParser.java b/src/itdelatrisu/opsu/OsuParser.java index d83da0f7..7e95e0fa 100644 --- a/src/itdelatrisu/opsu/OsuParser.java +++ b/src/itdelatrisu/opsu/OsuParser.java @@ -55,10 +55,8 @@ public class OsuParser { /** * Invokes parser for each OSU file in a root directory. * @param root the root directory (search has depth 1) - * @param width the container width - * @param height the container height */ - public static void parseAllFiles(File root, int width, int height) { + public static void parseAllFiles(File root) { // create a new OsuGroupList OsuGroupList.create(); diff --git a/src/itdelatrisu/opsu/Utils.java b/src/itdelatrisu/opsu/Utils.java index 7de530bd..5d32e8bb 100644 --- a/src/itdelatrisu/opsu/Utils.java +++ b/src/itdelatrisu/opsu/Utils.java @@ -20,6 +20,7 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.downloads.DownloadNode; import java.awt.Font; import java.awt.image.BufferedImage; @@ -71,7 +72,8 @@ public class Utils { COLOR_ORANGE_OBJECT = new Color(255, 200, 32), COLOR_YELLOW_ALPHA = new Color(255, 255, 0, 0.4f), COLOR_WHITE_FADE = new Color(255, 255, 255, 1f), - COLOR_RED_HOVER = new Color(255, 112, 112); + COLOR_RED_HOVER = new Color(255, 112, 112), + COLOR_GREEN = new Color(137, 201, 79); /** The default map colors, used when a map does not provide custom colors. */ public static final Color[] DEFAULT_COMBO = { @@ -201,6 +203,9 @@ public class Utils { // initialize score data buttons ScoreData.init(width, height); + // initialize download nodes + DownloadNode.init(width, height); + // back button Image back = GameImage.MENU_BACK.getImage(); backButton = new MenuButton(back, @@ -685,4 +690,18 @@ public class Utils { } } } + + /** + * Returns a human-readable representation of a given number of bytes. + * @param bytes the number of bytes + * @return the string representation + * @author aioobe (http://stackoverflow.com/a/3758880) + */ + public static String bytesToString(long bytes) { + if (bytes < 1024) + return bytes + " B"; + int exp = (int) (Math.log(bytes) / Math.log(1024)); + char pre = "KMGTPE".charAt(exp - 1); + return String.format("%.1f %cB", bytes / Math.pow(1024, exp), pre); + } } \ No newline at end of file diff --git a/src/itdelatrisu/opsu/downloads/BloodcatServer.java b/src/itdelatrisu/opsu/downloads/BloodcatServer.java new file mode 100644 index 00000000..cdc167de --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/BloodcatServer.java @@ -0,0 +1,134 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! 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. + * + * opsu! 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 opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads; + +import itdelatrisu.opsu.ErrorHandler; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.URLEncoder; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Download server: http://bloodcat.com/osu/ + */ +public class BloodcatServer implements DownloadServer { + /** Formatted download URL: {@code beatmapSetID} */ + private static final String DOWNLOAD_URL = "http://bloodcat.com/osu/s/%d"; + + /** Formatted search URL: {@code query,rankedOnly,page} */ + private static final String SEARCH_URL = "http://bloodcat.com/osu/?q=%s&m=b&c=%s&g=&d=0&s=date&o=0&p=%d&mod=json"; + + /** Total result count from the last query. */ + private int totalResults = -1; + + /** Constructor. */ + public BloodcatServer() {} + + @Override + public String getURL(int beatmapSetID) { + return String.format(DOWNLOAD_URL, beatmapSetID); + } + + @Override + public DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException { + DownloadNode[] nodes = null; + try { + // read JSON + String search = String.format(SEARCH_URL, URLEncoder.encode(query, "UTF-8"), rankedOnly ? "0" : "", page); + JSONObject json = readJsonFromUrl(new URL(search)); + if (json == null) { + this.totalResults = -1; + return null; + } + + // parse result list + JSONArray arr = json.getJSONArray("results"); + nodes = new DownloadNode[arr.length()]; + for (int i = 0; i < nodes.length; i++) { + JSONObject item = arr.getJSONObject(i); + nodes[i] = new DownloadNode( + item.getInt("id"), item.getString("date"), + item.getString("title"), item.isNull("titleUnicode") ? null : item.getString("titleUnicode"), + item.getString("artist"), item.isNull("artistUnicode") ? null : item.getString("artistUnicode"), + item.getString("creator") + ); + } + + // store total result count + this.totalResults = json.getInt("resultCount"); + } catch (MalformedURLException | UnsupportedEncodingException e) { + ErrorHandler.error(String.format("Problem loading result list for query '%s'.", query), e, true); + } + return nodes; + } + + @Override + public int totalResults() { return totalResults; } + + /** + * Returns a JSON object from a URL. + * @param url the remote URL + * @return the JSON object + * @author Roland Illig (http://stackoverflow.com/a/4308662) + */ + public static JSONObject readJsonFromUrl(URL url) throws IOException { + // open connection + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(Download.CONNECTION_TIMEOUT); + conn.setReadTimeout(Download.READ_TIMEOUT); + conn.setUseCaches(false); + try { + conn.connect(); + } catch (SocketTimeoutException e) { + ErrorHandler.error("Connection to server timed out.", e, false); + throw e; + } + + if (Thread.interrupted()) + return null; + + // read JSON + JSONObject json = null; + try (InputStream in = conn.getInputStream()) { + BufferedReader rd = new BufferedReader(new InputStreamReader(in)); + StringBuilder sb = new StringBuilder(); + int c; + while ((c = rd.read()) != -1) + sb.append((char) c); + json = new JSONObject(sb.toString()); + } catch (SocketTimeoutException e) { + ErrorHandler.error("Connection to server timed out.", e, false); + throw e; + } catch (JSONException e1) { + ErrorHandler.error("Failed to create JSON object.", e1, true); + } + return json; + } +} diff --git a/src/itdelatrisu/opsu/downloads/Download.java b/src/itdelatrisu/opsu/downloads/Download.java new file mode 100644 index 00000000..8c9df683 --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/Download.java @@ -0,0 +1,256 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! 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. + * + * opsu! 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 opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads; + +import itdelatrisu.opsu.ErrorHandler; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * File download. + */ +public class Download { + /** Connection timeout, in ms. */ + public static final int CONNECTION_TIMEOUT = 5000; + + /** Read timeout, in ms. */ + public static final int READ_TIMEOUT = 10000; + + /** Download statuses. */ + public enum Status { + WAITING ("Waiting"), + DOWNLOADING ("Downloading"), + COMPLETE ("Complete"), + CANCELLED ("Cancelled"), + ERROR ("Error"); + + /** The status name. */ + private String name; + + /** + * Constructor. + * @param name the status name. + */ + Status(String name) { + this.name = name; + } + + /** + * Returns the status name. + */ + public String getName() { return name; } + } + + /** The local path. */ + private String localPath; + + /** The local path to rename the file to when finished. */ + private String rename; + + /** The download URL. */ + private URL url; + + /** The readable byte channel. */ + private ReadableByteChannelWrapper rbc; + + /** The file output stream. */ + private FileOutputStream fos; + + /** The size of the download. */ + private int contentLength = -1; + + /** The download status. */ + private Status status = Status.WAITING; + + /** + * Constructor. + * @param remoteURL the download URL + * @param localPath the path to save the download + */ + public Download(String remoteURL, String localPath) { + this(remoteURL, localPath, null); + } + + /** + * Constructor. + * @param remoteURL the download URL + * @param localPath the path to save the download + * @param rename the file name to rename the download to when complete + */ + public Download(String remoteURL, String localPath, String rename) { + try { + this.url = new URL(remoteURL); + } catch (MalformedURLException e) { + this.status = Status.ERROR; + ErrorHandler.error(String.format("Bad download URL: '%s'", remoteURL), e, true); + return; + } + this.localPath = localPath; + this.rename = rename; + } + + /** + * Starts the download from the "waiting" status. + */ + public void start() { + if (status != Status.WAITING) + return; + + new Thread() { + @Override + public void run() { + // open connection, get content length + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(CONNECTION_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + conn.setUseCaches(false); + contentLength = conn.getContentLength(); + } catch (IOException e) { + status = Status.ERROR; + ErrorHandler.error("Failed to open connection.", e, false); + return; + } + + // download file + try ( + InputStream in = conn.getInputStream(); + ReadableByteChannel readableByteChannel = Channels.newChannel(in); + FileOutputStream fileOutputStream = new FileOutputStream(localPath); + ) { + rbc = new ReadableByteChannelWrapper(readableByteChannel); + fos = fileOutputStream; + status = Status.DOWNLOADING; + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + if (status == Status.DOWNLOADING) { // not interrupted + status = Status.COMPLETE; + rbc.close(); + fos.close(); + if (rename != null) { + Path source = new File(localPath).toPath(); + Files.move(source, source.resolveSibling(rename), StandardCopyOption.REPLACE_EXISTING); + } + } + } catch (Exception e) { + status = Status.ERROR; + ErrorHandler.error("Failed to start download.", e, false); + } + } + }.start(); + } + + /** + * Returns the download status. + */ + public Status getStatus() { return status; } + + /** + * Returns true if transfers are currently taking place. + */ + public boolean isTransferring() { + return (rbc != null && rbc.isOpen() && fos != null && fos.getChannel().isOpen()); + } + + /** + * Returns true if the download is active. + */ + public boolean isActive() { + return (status == Status.WAITING || status == Status.DOWNLOADING); + } + + /** + * Returns the size of the download content in bytes, or -1 if not calculated + * (or if an error has occurred). + */ + public int contentLength() { return contentLength; } + + /** + * Returns the download completion percentage, or -1f if an error has occurred. + */ + public float getProgress() { + switch (status) { + case WAITING: + return 0f; + case COMPLETE: + return 100f; + case DOWNLOADING: + if (rbc != null && fos != null && contentLength > 0) + return (float) rbc.getReadSoFar() / (float) contentLength * 100f; + else + return 0f; + case CANCELLED: + case ERROR: + default: + return -1f; + } + } + + /** + * Returns the number of bytes read so far. + */ + public long readSoFar() { + switch (status) { + case COMPLETE: + return contentLength; + case DOWNLOADING: + if (rbc != null) + return rbc.getReadSoFar(); + // else fall through + case WAITING: + case CANCELLED: + case ERROR: + default: + return 0; + } + } + + /** + * Cancels the download, if running. + */ + public void cancel() { + try { + this.status = Status.CANCELLED; + boolean transferring = isTransferring(); + if (rbc != null && rbc.isOpen()) + rbc.close(); + if (fos != null && fos.getChannel().isOpen()) + fos.close(); + if (transferring) { + File f = new File(localPath); + if (f.isFile()) + f.delete(); + } + } catch (IOException e) { + this.status = Status.ERROR; + ErrorHandler.error("Failed to cancel download.", e, true); + } + } +} diff --git a/src/itdelatrisu/opsu/downloads/DownloadList.java b/src/itdelatrisu/opsu/downloads/DownloadList.java new file mode 100644 index 00000000..8c5c943e --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/DownloadList.java @@ -0,0 +1,144 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! 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. + * + * opsu! 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 opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.swing.JOptionPane; +import javax.swing.UIManager; + +import org.newdawn.slick.util.Log; + +/** + * Maintains the current downloads list. + */ +public class DownloadList { + /** The single instance of this class. */ + private static DownloadList list = new DownloadList(); + + /** Current list of downloads. */ + private List nodes; + + /** The map of beatmap set IDs to DownloadNodes for the current downloads. */ + private Map map; + + /** + * Returns the single instance of this class. + */ + public static DownloadList get() { return list; } + + /** + * Constructor. + */ + private DownloadList() { + nodes = new ArrayList(); + map = new HashMap(); + } + + /** + * Returns the DownloadNode at an index. + */ + public DownloadNode getNode(int index) { return nodes.get(index); } + + /** + * Gets the Download for a beatmap set ID, or null if not in the list. + */ + public Download getDownload(int beatmapSetID) { + DownloadNode node = map.get(beatmapSetID); + return (node == null) ? null : node.getDownload(); + } + + /** + * Returns the size of the doownloads list. + */ + public int size() { return nodes.size(); } + + /** + * Returns {@code true} if this list contains no elements. + */ + public boolean isEmpty() { return nodes.isEmpty(); } + + /** + * Returns {@code true} if this list contains the beatmap set ID. + */ + public boolean contains(int beatmapSetID) { return map.containsKey(beatmapSetID); } + + /** + * Adds a DownloadNode to the list. + */ + public void addNode(DownloadNode node) { + nodes.add(node); + map.put(node.getID(), node); + } + + /** + * Removes a DownloadNode from the list. + */ + public void remove(DownloadNode node) { remove(nodes.indexOf(node)); } + + /** + * Removes a DownloadNode from the list at the given index. + */ + public void remove(int index) { + DownloadNode node = nodes.remove(index); + map.remove(node.getID()); + } + + /** + * Returns {@code true} if the list contains any downloads that are active. + */ + public boolean hasActiveDownloads() { + for (DownloadNode node: nodes) { + Download dl = node.getDownload(); + if (dl != null && dl.isActive()) + return true; + } + return false; + } + + /** + * Cancels all downloads. + */ + public void cancelAllDownloads() { + for (DownloadNode node : nodes) { + Download dl = node.getDownload(); + if (dl != null && dl.isActive()) + dl.cancel(); + } + } + + /** + * Shows a confirmation dialog (used before exiting the game). + * @return true if user selects "yes", false otherwise + */ + public static boolean showExitConfirmation() { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception e) { + Log.error("Could not set system look and feel for Container.", e); + } + int n = JOptionPane.showConfirmDialog(null, + "Beatmap downloads are in progress.\nAre you sure you want to quit opsu!?", + "Warning", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); + return (n != JOptionPane.YES_OPTION); + } +} diff --git a/src/itdelatrisu/opsu/downloads/DownloadNode.java b/src/itdelatrisu/opsu/downloads/DownloadNode.java new file mode 100644 index 00000000..615262c7 --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/DownloadNode.java @@ -0,0 +1,396 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! 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. + * + * opsu! 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 opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.Options; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.downloads.Download.Status; +import itdelatrisu.opsu.states.DownloadsMenu; + +import java.io.File; + +import org.newdawn.slick.Color; +import org.newdawn.slick.Graphics; +import org.newdawn.slick.Image; + +/** + * Node containing song data and a Download object. + */ +public class DownloadNode { + /** The associated Download object. */ + private Download download; + + /** Beatmap set ID. */ + private int beatmapSetID; + + /** Last updated date string. */ + private String date; + + /** Song title. */ + private String title, titleUnicode; + + /** Song artist. */ + private String artist, artistUnicode; + + /** Beatmap creator. */ + private String creator; + + /** Button drawing values. */ + private static float buttonBaseX, buttonBaseY, buttonWidth, buttonHeight, buttonOffset; + + /** Information drawing values. */ + private static float infoBaseX, infoBaseY, infoWidth, infoHeight; + + /** Container dimensions. */ + private static int containerWidth, containerHeight; + + /** Button background colors. */ + public static final Color + BG_NORMAL = new Color(0, 0, 0, 0.25f), + BG_HOVER = new Color(0, 0, 0, 0.5f), + BG_FOCUS = new Color(0, 0, 0, 0.75f); + + /** + * Initializes the base coordinates for drawing. + * @param width the container width + * @param height the container height + */ + public static void init(int width, int height) { + containerWidth = width; + containerHeight = height; + + // download result buttons + buttonBaseX = width * 0.024f; + buttonBaseY = height * 0.2f; + buttonWidth = width * 0.7f; + buttonHeight = Utils.FONT_MEDIUM.getLineHeight() * 2f; + buttonOffset = buttonHeight * 1.1f; + + // download info + infoBaseX = width * 0.75f; + infoBaseY = height * 0.07f + Utils.FONT_LARGE.getLineHeight() * 2f; + infoWidth = width * 0.25f; + infoHeight = Utils.FONT_DEFAULT.getLineHeight() * 2.4f; + } + + /** + * Returns true if the coordinates are within the bounds of the + * download result button at the given index. + * @param cx the x coordinate + * @param cy the y coordinate + * @param index the index (to offset the button from the topmost button) + */ + public static boolean resultContains(float cx, float cy, int index) { + float y = buttonBaseY + (index * buttonOffset); + return ((cx > buttonBaseX && cx < buttonBaseX + buttonWidth) && + (cy > y && cy < y + buttonHeight)); + } + + /** + * Returns true if the coordinates are within the bounds of the + * download result button area. + * @param cx the x coordinate + * @param cy the y coordinate + */ + public static boolean resultAreaContains(float cx, float cy) { + return ((cx > buttonBaseX && cx < buttonBaseX + buttonWidth) && + (cy > buttonBaseY && cy < buttonBaseY + buttonOffset * DownloadsMenu.MAX_RESULT_BUTTONS)); + } + + /** + * Returns true if the coordinates are within the bounds of the + * download information button at the given index. + * @param cx the x coordinate + * @param cy the y coordinate + * @param index the index (to offset the button from the topmost button) + */ + public static boolean downloadContains(float cx, float cy, int index) { + float y = infoBaseY + (index * infoHeight); + return ((cx > infoBaseX && cx <= containerWidth) && + (cy > y && cy < y + infoHeight)); + } + + /** + * Returns true if the coordinates are within the bounds of the + * download action icon at the given index. + * @param cx the x coordinate + * @param cy the y coordinate + * @param index the index (to offset the button from the topmost button) + */ + public static boolean downloadIconContains(float cx, float cy, int index) { + int iconWidth = GameImage.DELETE.getImage().getWidth(); + float edgeX = infoBaseX + infoWidth * 0.985f; + float y = infoBaseY + (index * infoHeight); + float marginY = infoHeight * 0.04f; + return ((cx > edgeX - iconWidth && cx < edgeX) && + (cy > y + marginY && cy < y + marginY + iconWidth)); + } + + /** + * Returns true if the coordinates are within the bounds of the + * download information button area. + * @param cx the x coordinate + * @param cy the y coordinate + */ + public static boolean downloadAreaContains(float cx, float cy) { + return ((cx > infoBaseX && cx <= containerWidth) && + (cy > infoBaseY && cy < infoBaseY + infoHeight * DownloadsMenu.MAX_DOWNLOADS_SHOWN)); + } + + /** + * Returns true if the coordinates are within the bounds of the + * previous page icon. + * @param cx the x coordinate + * @param cy the y coordinate + */ + public static boolean prevPageContains(float cx, float cy) { + Image img = GameImage.MUSIC_PREVIOUS.getImage(); + return ((cx > buttonBaseX && cx < buttonBaseX + img.getWidth()) && + (cy > buttonBaseY - img.getHeight() && cy < buttonBaseY)); + } + + /** + * Returns true if the coordinates are within the bounds of the + * next page icon. + * @param cx the x coordinate + * @param cy the y coordinate + */ + public static boolean nextPageContains(float cx, float cy) { + Image img = GameImage.MUSIC_NEXT.getImage(); + return ((cx > buttonBaseX + buttonWidth - img.getWidth() && cx < buttonBaseX + buttonWidth) && + (cy > buttonBaseY - img.getHeight() && cy < buttonBaseY)); + } + + /** + * Draws the scroll bar for the download result buttons. + * @param g the graphics context + * @param index the start button index + * @param total the total number of buttons + */ + public static void drawResultScrollbar(Graphics g, int index, int total) { + float scrollbarWidth = containerWidth * 0.00347f; + float heightRatio = 0.0016f * (total * total) - 0.0705f * total + 0.9965f; + float scrollbarHeight = containerHeight * heightRatio; + float heightDiff = buttonHeight + buttonOffset * (DownloadsMenu.MAX_RESULT_BUTTONS - 1) - scrollbarHeight; + float offsetY = heightDiff * ((float) index / (total - DownloadsMenu.MAX_RESULT_BUTTONS)); + g.setColor(BG_NORMAL); + g.fillRect(buttonBaseX + buttonWidth * 1.005f, buttonBaseY, scrollbarWidth, buttonOffset * DownloadsMenu.MAX_RESULT_BUTTONS); + g.setColor(Color.white); + g.fillRect(buttonBaseX + buttonWidth * 1.005f, buttonBaseY + offsetY, scrollbarWidth, scrollbarHeight); + } + + /** + * Draws the scroll bar for the download information area. + * @param g the graphics context + * @param index the start index + * @param total the total number of downloads + */ + public static void drawDownloadScrollbar(Graphics g, int index, int total) { + float scrollbarWidth = containerWidth * 0.00347f; + float heightRatio = 0.0016f * (total * total) - 0.0705f * total + 0.9965f; + float scrollbarHeight = containerHeight * heightRatio; + float heightDiff = infoHeight + infoHeight * (DownloadsMenu.MAX_DOWNLOADS_SHOWN - 1) - scrollbarHeight; + float offsetY = heightDiff * ((float) index / (total - DownloadsMenu.MAX_DOWNLOADS_SHOWN)); + g.setColor(BG_NORMAL); + g.fillRect(infoBaseX + infoWidth - scrollbarWidth, infoBaseY, scrollbarWidth, infoHeight * DownloadsMenu.MAX_DOWNLOADS_SHOWN); + g.setColor(Color.white); + g.fillRect(infoBaseX + infoWidth - scrollbarWidth, infoBaseY + offsetY, scrollbarWidth, scrollbarHeight); + } + + /** + * Draws the page number text and previous/next page icons. + * @param page the current page number + * @param prev whether to draw the previous page icon + * @param next whether to draw the next page icon + */ + public static void drawPageIcons(int page, boolean prev, boolean next) { + String pageText = String.format("Page %d", page); + Utils.FONT_BOLD.drawString( + buttonBaseX + (buttonWidth - Utils.FONT_BOLD.getWidth("Page 1")) / 2f, + buttonBaseY - Utils.FONT_BOLD.getLineHeight() * 1.3f, pageText, Color.white); + if (prev) { + Image prevImg = GameImage.MUSIC_PREVIOUS.getImage(); + prevImg.draw(buttonBaseX, buttonBaseY - prevImg.getHeight()); + } + if (next) { + Image nextImg = GameImage.MUSIC_NEXT.getImage(); + nextImg.draw(buttonBaseX + buttonWidth - nextImg.getWidth(), buttonBaseY - nextImg.getHeight()); + } + } + + /** + * Constructor. + */ + public DownloadNode(int beatmapSetID, String date, String title, + String titleUnicode, String artist, String artistUnicode, String creator) { + this.beatmapSetID = beatmapSetID; + this.date = date; + this.title = title; + this.titleUnicode = titleUnicode; + this.artist = artist; + this.artistUnicode = artistUnicode; + this.creator = creator; + } + + /** + * Creates a download object for this node. + * @param server the server to download from + * @see #getDownload() + */ + public void createDownload(DownloadServer server) { + if (download == null) { + String path = String.format("%s%c%d", Options.getOSZDir(), File.separatorChar, beatmapSetID); + String rename = String.format("%d %s - %s.osz", beatmapSetID, artist, title); + this.download = new Download(server.getURL(beatmapSetID), path, rename); + } + } + + /** + * Returns the associated download object, or null if none. + * @see #createDownload(DownloadServer) + */ + public Download getDownload() { return download; } + + /** + * Clears the associated download object, if any. + * @see #createDownload(DownloadServer) + */ + public void clearDownload() { download = null; } + + /** + * Returns the beatmap set ID. + */ + public int getID() { return beatmapSetID; } + + /** + * Returns the last updated date. + */ + public String getDate() { return date; } + + /** + * Returns the song title. + * If configured, the Unicode string will be returned instead. + */ + public String getTitle() { + return (Options.useUnicodeMetadata() && titleUnicode != null && !titleUnicode.isEmpty()) ? titleUnicode : title; + } + + /** + * Returns the song artist. + * If configured, the Unicode string will be returned instead. + */ + public String getArtist() { + return (Options.useUnicodeMetadata() && artistUnicode != null && !artistUnicode.isEmpty()) ? artistUnicode : artist; + } + + /** + * Returns the song creator. + */ + public String getCreator() { return creator; } + + /** + * Draws the download result as a rectangular button. + * @param g the graphics context + * @param index the index (to offset the button from the topmost button) + * @param hover true if the mouse is hovering over this button + * @param focus true if the button is focused + */ + public void drawResult(Graphics g, int index, boolean hover, boolean focus) { + float textX = buttonBaseX + buttonWidth * 0.02f; + float edgeX = buttonBaseX + buttonWidth * 0.985f; + float y = buttonBaseY + index * buttonOffset; + float marginY = buttonHeight * 0.04f; + Download dl = DownloadList.get().getDownload(beatmapSetID); + + // rectangle outline + g.setColor((focus) ? BG_FOCUS : (hover) ? BG_HOVER : BG_NORMAL); + g.fillRect(buttonBaseX, y, buttonWidth, buttonHeight); + + // download progress + if (dl != null) { + float progress = dl.getProgress(); + if (progress > 0f) { + g.setColor(Utils.COLOR_GREEN); + g.fillRect(buttonBaseX, y, buttonWidth * progress / 100f, buttonHeight); + } + } + + // text + Utils.FONT_BOLD.drawString( + textX, y + marginY, + String.format("%s - %s%s", getArtist(), getTitle(), + (dl != null) ? String.format(" [%s]", dl.getStatus().getName()) : ""), Color.white); + Utils.FONT_DEFAULT.drawString( + textX, y + marginY + Utils.FONT_BOLD.getLineHeight(), + String.format("Last updated: %s", date), Color.white); + Utils.FONT_DEFAULT.drawString( + edgeX - Utils.FONT_DEFAULT.getWidth(creator), y + marginY, + creator, Color.white); + } + + /** + * Draws the download information. + * @param g the graphics context + * @param index the index (to offset from the topmost position) + * @param id the list index + * @param hover true if the mouse is hovering over this button + */ + public void drawDownload(Graphics g, int index, int id, boolean hover) { + if (download == null) { + ErrorHandler.error("Trying to draw download information for button without Download object.", null, false); + return; + } + + float textX = infoBaseX + infoWidth * 0.02f; + float edgeX = infoBaseX + infoWidth * 0.985f; + float y = infoBaseY + index * infoHeight; + float marginY = infoHeight * 0.04f; + + // rectangle outline + g.setColor((id % 2 == 0) ? BG_FOCUS : BG_NORMAL); + g.fillRect(infoBaseX, y, infoWidth, infoHeight); + + // text + String info; + Status status = download.getStatus(); + float progress = download.getProgress(); + if (progress < 0f) + info = status.getName(); + else if (status == Download.Status.WAITING) + info = String.format("%s...", status.getName()); + else + info = String.format("%s: %.1f%% (%s/%s)", status.getName(), progress, + Utils.bytesToString(download.readSoFar()), Utils.bytesToString(download.contentLength())); + Utils.FONT_BOLD.drawString(textX, y + marginY, getTitle(), Color.white); + Utils.FONT_DEFAULT.drawString(textX, y + marginY + Utils.FONT_BOLD.getLineHeight(), info, Color.white); + + // 'x' button + if (hover) { + Image img = GameImage.DELETE.getImage(); + img.draw(edgeX - img.getWidth(), y + marginY); + } + } + + @Override + public String toString() { + return String.format("[%d] %s - %s (by %s)", beatmapSetID, getArtist(), getTitle(), creator); + } +} diff --git a/src/itdelatrisu/opsu/downloads/DownloadServer.java b/src/itdelatrisu/opsu/downloads/DownloadServer.java new file mode 100644 index 00000000..995185da --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/DownloadServer.java @@ -0,0 +1,52 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! 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. + * + * opsu! 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 opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads; + +import java.io.IOException; + +/** + * Interface for beatmap download servers. + */ +public interface DownloadServer { + /** + * Returns a web address to download the given beatmap. + * @param beatmapSetID the beatmap set ID + * @return the URL string + */ + public String getURL(int beatmapSetID); + + /** + * Returns a list of results for a given search query, or null if the + * list could not be created. + * @param query the search query + * @param page the result page (starting at 1) + * @param rankedOnly whether to only show ranked maps + * @return the result array + * @throws IOException if any connection problem occurs + */ + public DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException; + + /** + * Returns the total number of results for the last search query. + * This will differ from the the size of the array returned by + * {@link #resultList(String, int, boolean)} if multiple pages exist. + * @return the result count, or -1 if no query + */ + public int totalResults(); +} diff --git a/src/itdelatrisu/opsu/downloads/ReadableByteChannelWrapper.java b/src/itdelatrisu/opsu/downloads/ReadableByteChannelWrapper.java new file mode 100644 index 00000000..786b2c5d --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/ReadableByteChannelWrapper.java @@ -0,0 +1,62 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! 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. + * + * opsu! 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 opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ReadableByteChannel; + +/** + * Wrapper for a ReadableByteChannel that stores the number of bytes read. + * @author par (http://stackoverflow.com/a/11068356) + */ +public class ReadableByteChannelWrapper implements ReadableByteChannel { + /** The wrapped ReadableByteChannel. */ + private ReadableByteChannel rbc; + + /** The number of bytes read. */ + private long bytesRead; + + /** + * Constructor. + * @param rbc the ReadableByteChannel to wrap + */ + public ReadableByteChannelWrapper(ReadableByteChannel rbc) { + this.rbc = rbc; + } + + @Override + public void close() throws IOException { rbc.close(); } + + @Override + public boolean isOpen() { return rbc.isOpen(); } + + @Override + public int read(ByteBuffer bb) throws IOException { + int bytes; + if ((bytes = rbc.read(bb)) > 0) + bytesRead += bytes; + return bytes; + } + + /** + * Returns the number of bytes read so far. + */ + public long getReadSoFar() { return bytesRead; } +} diff --git a/src/itdelatrisu/opsu/states/DownloadsMenu.java b/src/itdelatrisu/opsu/states/DownloadsMenu.java new file mode 100644 index 00000000..a6f4de34 --- /dev/null +++ b/src/itdelatrisu/opsu/states/DownloadsMenu.java @@ -0,0 +1,552 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! 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. + * + * opsu! 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 opsu!. If not, see . + */ + +package itdelatrisu.opsu.states; + +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.Opsu; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.audio.SoundController; +import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.downloads.BloodcatServer; +import itdelatrisu.opsu.downloads.Download; +import itdelatrisu.opsu.downloads.DownloadList; +import itdelatrisu.opsu.downloads.DownloadNode; +import itdelatrisu.opsu.downloads.DownloadServer; + +import java.io.IOException; + +import org.newdawn.slick.Color; +import org.newdawn.slick.GameContainer; +import org.newdawn.slick.Graphics; +import org.newdawn.slick.Input; +import org.newdawn.slick.SlickException; +import org.newdawn.slick.gui.TextField; +import org.newdawn.slick.state.BasicGameState; +import org.newdawn.slick.state.StateBasedGame; +import org.newdawn.slick.state.transition.FadeInTransition; +import org.newdawn.slick.state.transition.FadeOutTransition; + +/** + * Downloads menu. + */ +public class DownloadsMenu extends BasicGameState { + /** The max number of search result buttons to be shown at a time. */ + public static final int MAX_RESULT_BUTTONS = 10; + + /** The max number of downloads to be shown at a time. */ + public static final int MAX_DOWNLOADS_SHOWN = 11; + + /** Delay time, in milliseconds, between each search. */ + private static final int SEARCH_DELAY = 700; + + /** Delay time, in milliseconds, for double-clicking focused result. */ + private static final int FOCUS_DELAY = 250; + + /** Minimum time, in milliseconds, that must elapse between queries. */ + private static final int MIN_REQUEST_INTERVAL = 300; + + /** The beatmap download server. */ + private DownloadServer server = new BloodcatServer(); + + /** The current list of search results. */ + private DownloadNode[] resultList; + + /** Current focused (selected) result. */ + private int focusResult = -1; + + /** Delay time, in milliseconds, for double-clicking focused result. */ + private int focusTimer = 0; + + /** Current start result button (topmost entry). */ + private int startResult = 0; + + /** Total number of results for current query. */ + private int totalResults = 0; + + /** Page of current query results. */ + private int page = 1; + + /** Total number of results across pages seen so far. */ + private int pageResultTotal = 0; + + /** Page navigation. */ + private enum Page { RESET, CURRENT, PREVIOUS, NEXT }; + + /** Page direction for next query. */ + private Page pageDir = Page.RESET; + + /** Whether to only show ranked maps. */ + private boolean rankedOnly = true; + + /** Current start download index. */ + private int startDownloadIndex = 0; + + /** Query thread. */ + private Thread queryThread; + + /** The search textfield. */ + private TextField search; + + /** + * Delay timer, in milliseconds, before running another search. + * This is overridden by character entry (reset) and 'esc'/'enter' (immediate search). + */ + private int searchTimer; + + /** Information text to display based on the search query. */ + private String searchResultString; + + /** Whether or not the search timer has been manually reset; reset after search delay passes. */ + private boolean searchTimerReset = false; + + /** The last search query. */ + private String lastQuery; + + /** Page direction for last query. */ + private Page lastQueryDir = Page.RESET; + + /** Number of active requests. */ + private int activeRequests = 0; + + /** "Ranked only?" checkbox coordinates. */ + private float rankedBoxX, rankedBoxY, rankedBoxLength; + + // game-related variables + private StateBasedGame game; + private Input input; + private int state; + + public DownloadsMenu(int state) { + this.state = state; + } + + @Override + public void init(GameContainer container, StateBasedGame game) + throws SlickException { + this.game = game; + this.input = container.getInput(); + + int width = container.getWidth(); + int height = container.getHeight(); + + // search + searchTimer = SEARCH_DELAY; + searchResultString = "Type to search!"; + search = new TextField( + container, Utils.FONT_DEFAULT, + (int) (width * 0.024f), (int) (height * 0.05f) + Utils.FONT_LARGE.getLineHeight(), + (int) (width * 0.35f), Utils.FONT_MEDIUM.getLineHeight() + ); + search.setBackgroundColor(DownloadNode.BG_NORMAL); + search.setBorderColor(Color.white); + search.setTextColor(Color.white); + search.setConsumeEvents(false); + search.setMaxLength(255); + + // ranked only? + rankedBoxX = search.getX() + search.getWidth() * 1.2f; + rankedBoxY = search.getY(); + rankedBoxLength = search.getHeight(); + } + + @Override + public void render(GameContainer container, StateBasedGame game, Graphics g) + throws SlickException { + int width = container.getWidth(); + int height = container.getHeight(); + int mouseX = input.getMouseX(), mouseY = input.getMouseY(); + + // background + GameImage.SEARCH_BG.getImage().draw(); + + // title + Utils.FONT_LARGE.drawString(width * 0.024f, height * 0.04f, "Download Beatmaps!", Color.white); + + // search + g.setColor(Color.white); + search.render(container, g); + Utils.FONT_BOLD.drawString( + search.getX() + search.getWidth() * 0.01f, search.getY() + search.getHeight() * 1.3f, + searchResultString, Color.white + ); + + // ranked only? + if (rankedOnly) + g.fillRect(rankedBoxX, rankedBoxY, rankedBoxLength, rankedBoxLength); + else + g.drawRect(rankedBoxX, rankedBoxY, rankedBoxLength, rankedBoxLength); + Utils.FONT_MEDIUM.drawString(rankedBoxX + rankedBoxLength * 1.5f, rankedBoxY, "Show ranked maps only?", Color.white); + + // search results + DownloadNode[] nodes = resultList; + if (nodes != null) { + for (int i = 0; i < MAX_RESULT_BUTTONS; i++) { + int index = startResult + i; + if (index >= nodes.length) + break; + nodes[index].drawResult(g, i, DownloadNode.resultContains(mouseX, mouseY, i), (index == focusResult)); + } + + // scroll bar + if (nodes.length > MAX_RESULT_BUTTONS) + DownloadNode.drawResultScrollbar(g, startResult, nodes.length); + + // pages + if (nodes.length > 0) + DownloadNode.drawPageIcons(page, (page > 1), (pageResultTotal < totalResults)); + } + + // downloads + float downloadsX = width * 0.75f, downloadsY = search.getY(); + g.setColor(DownloadNode.BG_NORMAL); + g.fillRect(downloadsX, downloadsY, + width * 0.25f, height - downloadsY * 2f); + Utils.FONT_LARGE.drawString(downloadsX + width * 0.015f, downloadsY + height * 0.015f, "Downloads", Color.white); + int downloadsSize = DownloadList.get().size(); + if (downloadsSize > 0) { + for (int i = 0; i < MAX_DOWNLOADS_SHOWN; i++) { + int index = startDownloadIndex + i; + if (index >= downloadsSize) + break; + DownloadList.get().getNode(index).drawDownload(g, i, index, DownloadNode.downloadContains(mouseX, mouseY, i)); + } + + // scroll bar + if (downloadsSize > MAX_DOWNLOADS_SHOWN) + DownloadNode.drawDownloadScrollbar(g, startDownloadIndex, downloadsSize); + } + + Utils.getBackButton().draw(); + Utils.drawVolume(g); + Utils.drawFPS(); + Utils.drawCursor(); + } + + @Override + public void update(GameContainer container, StateBasedGame game, int delta) + throws SlickException { + Utils.updateCursor(delta); + Utils.updateVolumeDisplay(delta); + int mouseX = input.getMouseX(), mouseY = input.getMouseY(); + Utils.getBackButton().hoverUpdate(delta, mouseX, mouseY); + + // focus timer + if (focusResult != -1 && focusTimer < FOCUS_DELAY) + focusTimer += delta; + + // search + search.setFocus(true); + searchTimer += delta; + if (searchTimer >= SEARCH_DELAY) { + searchTimer = 0; + searchTimerReset = false; + + final String query = search.getText().trim().toLowerCase(); + if (lastQuery == null || !query.equals(lastQuery)) { + lastQuery = query; + lastQueryDir = pageDir; + + if (queryThread != null && queryThread.isAlive()) + queryThread.interrupt(); + + // execute query + queryThread = new Thread() { + @Override + public void run() { + activeRequests++; + + // check page direction + Page lastPageDir = pageDir; + pageDir = Page.RESET; + int lastPageSize = (resultList != null) ? resultList.length : 0; + int newPage = page; + if (lastPageDir == Page.RESET) + newPage = 1; + else if (lastPageDir == Page.NEXT) + newPage++; + else if (lastPageDir == Page.PREVIOUS) + newPage--; + try { + DownloadNode[] nodes = server.resultList(query, newPage, rankedOnly); + if (activeRequests - 1 == 0) { + // update page total + page = newPage; + if (nodes != null) { + if (lastPageDir == Page.NEXT) + pageResultTotal += nodes.length; + else if (lastPageDir == Page.PREVIOUS) + pageResultTotal -= lastPageSize; + else if (lastPageDir == Page.RESET) + pageResultTotal = nodes.length; + } else + pageResultTotal = 0; + + resultList = nodes; + totalResults = server.totalResults(); + focusResult = -1; + startResult = 0; + if (nodes == null) + searchResultString = "An error has occurred."; + else { + if (query.isEmpty()) + searchResultString = "Type to search!"; + else if (totalResults == 0) + searchResultString = "No results found."; + else + searchResultString = String.format("%d result%s found!", + totalResults, (totalResults == 1) ? "" : "s"); + } + } + } catch (IOException e) { + searchResultString = "Could not establish connection to server."; + } finally { + activeRequests--; + queryThread = null; + } + } + }; + queryThread.start(); + } + } + } + + @Override + public int getID() { return state; } + + @Override + public void mousePressed(int button, int x, int y) { + // check mouse button + if (button != Input.MOUSE_LEFT_BUTTON) + return; + + // back + if (Utils.getBackButton().contains(x, y)) { + SoundController.playSound(SoundEffect.MENUBACK); + ((MainMenu) game.getState(Opsu.STATE_MAINMENU)).reset(); + game.enterState(Opsu.STATE_MAINMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); + return; + } + + // ranked only? + if ((x > rankedBoxX && x < rankedBoxX + rankedBoxLength) && + (y > rankedBoxY && y < rankedBoxY + rankedBoxLength)) { + rankedOnly = !rankedOnly; + lastQuery = null; + pageDir = Page.CURRENT; + resetSearchTimer(); + return; + } + + // search results + DownloadNode[] nodes = resultList; + if (nodes != null) { + if (DownloadNode.resultAreaContains(x, y)) { + for (int i = 0; i < MAX_RESULT_BUTTONS; i++) { + int index = startResult + i; + if (index >= nodes.length) + break; + if (DownloadNode.resultContains(x, y, i)) { + if (index == focusResult) { + if (focusTimer >= FOCUS_DELAY) { + // too slow for double-click + focusTimer = 0; + } else { + // start download + DownloadNode node = nodes[index]; + if (!DownloadList.get().contains(node.getID())) { + DownloadList.get().addNode(node); + node.createDownload(server); + node.getDownload().start(); + } + } + } else { + // set focus + focusResult = index; + focusTimer = 0; + } + break; + } + } + return; + } + + // pages + if (nodes.length > 0) { + if (page > 1 && DownloadNode.prevPageContains(x, y)) { + if (lastQueryDir == Page.PREVIOUS && queryThread != null && queryThread.isAlive()) + ; // don't send consecutive requests + else { + pageDir = Page.PREVIOUS; + lastQuery = null; + resetSearchTimer(); + } + return; + } + if (pageResultTotal < totalResults && DownloadNode.nextPageContains(x, y)) { + if (lastQueryDir == Page.NEXT && queryThread != null && queryThread.isAlive()) + ; // don't send consecutive requests + else { + pageDir = Page.NEXT; + lastQuery = null; + resetSearchTimer(); + return; + } + } + } + } + + // downloads + if (!DownloadList.get().isEmpty() && DownloadNode.downloadAreaContains(x, y)) { + for (int i = 0, n = DownloadList.get().size(); i < MAX_DOWNLOADS_SHOWN; i++) { + int index = startDownloadIndex + i; + if (index >= n) + break; + if (DownloadNode.downloadIconContains(x, y, i)) { + DownloadNode node = DownloadList.get().getNode(index); + Download dl = node.getDownload(); + switch (dl.getStatus()) { + case CANCELLED: + case COMPLETE: + case ERROR: + node.clearDownload(); + DownloadList.get().remove(index); + break; + case DOWNLOADING: + case WAITING: + dl.cancel(); + break; + } + return; + } + } + } + } + + @Override + public void mouseWheelMoved(int newValue) { + int shift = (newValue < 0) ? 1 : -1; + int mouseX = input.getMouseX(), mouseY = input.getMouseY(); + scrollLists(mouseX, mouseY, shift); + } + + @Override + public void mouseDragged(int oldx, int oldy, int newx, int newy) { + // check mouse button + if (!input.isMouseButtonDown(Input.MOUSE_RIGHT_BUTTON) && + !input.isMouseButtonDown(Input.MOUSE_LEFT_BUTTON)) + return; + + int diff = newy - oldy; + if (diff == 0) + return; + int shift = (diff < 0) ? 1 : -1; + scrollLists(oldx, oldy, shift); + } + + @Override + public void keyPressed(int key, char c) { + switch (key) { + case Input.KEY_ESCAPE: + if (!search.getText().isEmpty()) { + // clear search text + search.setText(""); + pageDir = Page.RESET; + resetSearchTimer(); + } else { + // return to main menu + SoundController.playSound(SoundEffect.MENUBACK); + ((MainMenu) game.getState(Opsu.STATE_MAINMENU)).reset(); + game.enterState(Opsu.STATE_MAINMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); + } + break; + case Input.KEY_ENTER: + if (!search.getText().isEmpty()) { + pageDir = Page.RESET; + resetSearchTimer(); + } + break; + case Input.KEY_F5: + lastQuery = null; + pageDir = Page.CURRENT; + resetSearchTimer(); + break; + case Input.KEY_F12: + Utils.takeScreenShot(); + break; + default: + // wait for user to finish typing + if (Character.isLetterOrDigit(c) || key == Input.KEY_BACK) { + searchTimer = 0; + pageDir = Page.RESET; + } + break; + } + } + + @Override + public void enter(GameContainer container, StateBasedGame game) + throws SlickException { + Utils.getBackButton().setScale(1f); + focusResult = -1; + startResult = 0; + startDownloadIndex = 0; + pageDir = Page.RESET; + } + + /** + * Resets the search timer, but respects the minimum request interval. + */ + private void resetSearchTimer() { + if (!searchTimerReset) { + if (searchTimer < MIN_REQUEST_INTERVAL) + searchTimer = SEARCH_DELAY - MIN_REQUEST_INTERVAL; + else + searchTimer = SEARCH_DELAY; + searchTimerReset = true; + } + } + + /** + * Processes a shift in the search result and downloads list start indices, + * if the mouse coordinates are within the area bounds. + * @param cx the x coordinate + * @param cy the y coordinate + * @param shift the number of indices to shift + */ + private void scrollLists(int cx, int cy, int shift) { + // search results + if (DownloadNode.resultAreaContains(cx, cy)) { + DownloadNode[] nodes = resultList; + if (nodes != null && nodes.length >= MAX_RESULT_BUTTONS) { + int newStartResult = startResult + shift; + if (newStartResult >= 0 && newStartResult + MAX_RESULT_BUTTONS <= nodes.length) + startResult = newStartResult; + } + } + + // downloads + else if (DownloadNode.downloadAreaContains(cx, cy)) { + if (DownloadList.get().size() >= MAX_DOWNLOADS_SHOWN) { + int newStartDownloadIndex = startDownloadIndex + shift; + if (newStartDownloadIndex >= 0 && newStartDownloadIndex + MAX_DOWNLOADS_SHOWN <= DownloadList.get().size()) + startDownloadIndex = newStartDownloadIndex; + } + } + } +} diff --git a/src/itdelatrisu/opsu/states/MainMenu.java b/src/itdelatrisu/opsu/states/MainMenu.java index 9a630c3a..f8ac128d 100644 --- a/src/itdelatrisu/opsu/states/MainMenu.java +++ b/src/itdelatrisu/opsu/states/MainMenu.java @@ -21,6 +21,7 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.MenuButton; +import itdelatrisu.opsu.MenuButton.Expand; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; import itdelatrisu.opsu.OsuFile; @@ -75,6 +76,9 @@ public class MainMenu extends BasicGameState { /** Music control buttons. */ private MenuButton musicPlay, musicPause, musicNext, musicPrevious; + /** Button linking to Downloads menu. */ + private MenuButton downloadsButton; + /** Button linking to repository. */ private MenuButton repoButton; @@ -138,6 +142,12 @@ public class MainMenu extends BasicGameState { musicNext.setHoverScale(1.5f); musicPrevious.setHoverScale(1.5f); + // initialize downloads button + Image dlImg = GameImage.DOWNLOADS.getImage(); + downloadsButton = new MenuButton(dlImg, width - dlImg.getWidth() / 2f, height / 2f); + downloadsButton.setHoverDir(Expand.LEFT); + downloadsButton.setHoverScale(1.05f); + // initialize repository button if (Desktop.isDesktopSupported()) { // only if a webpage can be opened Image repoImg = GameImage.REPOSITORY.getImage(); @@ -173,6 +183,9 @@ public class MainMenu extends BasicGameState { g.fillRect(0, height * 8 / 9f, width, height / 9f); Utils.COLOR_BLACK_ALPHA.a = oldAlpha; + // draw downloads button + downloadsButton.draw(); + // draw buttons if (logoTimer > 0) { playButton.draw(); @@ -239,6 +252,7 @@ public class MainMenu extends BasicGameState { exitButton.hoverUpdate(delta, mouseX, mouseY); if (repoButton != null) repoButton.hoverUpdate(delta, mouseX, mouseY); + downloadsButton.hoverUpdate(delta, mouseX, mouseY); musicPlay.hoverUpdate(delta, mouseX, mouseY); musicPause.hoverUpdate(delta, mouseX, mouseY); if (musicPlay.contains(mouseX, mouseY)) @@ -319,6 +333,8 @@ public class MainMenu extends BasicGameState { musicPrevious.setScale(1f); if (repoButton != null && !repoButton.contains(mouseX, mouseY)) repoButton.setScale(1f); + if (!downloadsButton.contains(mouseX, mouseY)) + downloadsButton.setScale(1f); } @Override @@ -355,6 +371,12 @@ public class MainMenu extends BasicGameState { MusicController.setPosition(0); } + // downloads button actions + else if (downloadsButton.contains(x, y)) { + SoundController.playSound(SoundEffect.MENUHIT); + game.enterState(Opsu.STATE_DOWNLOADSMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); + } + // repository button actions else if (repoButton != null && repoButton.contains(x, y)) { try { @@ -437,5 +459,6 @@ public class MainMenu extends BasicGameState { musicPause.setScale(1f); musicNext.setScale(1f); musicPrevious.setScale(1f); + downloadsButton.setScale(1f); } } diff --git a/src/itdelatrisu/opsu/states/SongMenu.java b/src/itdelatrisu/opsu/states/SongMenu.java index c4cb37ba..db8988eb 100644 --- a/src/itdelatrisu/opsu/states/SongMenu.java +++ b/src/itdelatrisu/opsu/states/SongMenu.java @@ -620,7 +620,7 @@ public class SongMenu extends BasicGameState { // invoke unpacker and parser File beatmapDir = Options.getBeatmapDir(); OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); - OsuParser.parseAllFiles(beatmapDir, container.getWidth(), container.getHeight()); + OsuParser.parseAllFiles(beatmapDir); // initialize song list if (OsuGroupList.get().size() > 0) { diff --git a/src/itdelatrisu/opsu/states/Splash.java b/src/itdelatrisu/opsu/states/Splash.java index 53c038d4..93bdebef 100644 --- a/src/itdelatrisu/opsu/states/Splash.java +++ b/src/itdelatrisu/opsu/states/Splash.java @@ -93,8 +93,6 @@ public class Splash extends BasicGameState { finished = true; } else { // load resources in a new thread - final int width = container.getWidth(); - final int height = container.getHeight(); thread = new Thread() { @Override public void run() { @@ -104,7 +102,7 @@ public class Splash extends BasicGameState { OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); // parse song directory - OsuParser.parseAllFiles(beatmapDir, width, height); + OsuParser.parseAllFiles(beatmapDir); // load sounds SoundController.init();