Implemented an internal beatmap downloader (using Bloodcat).

The downloads menu can be accessed through the button on the right side of the main menu.  The downloader supports searching and concurrent downloads (NOTE: this is limited by the download server!).  Double-click any search result to begin downloading it to the SongPacks directory; cancel the download by hitting the red 'x' in the upper-right corner.  A confirmation will appear if trying to quit opsu! while downloads are running.

New classes:
- Download: represents an individual download from a remote address to a local path, and provides status and progress information; downloads files using Java NIO.
- DownloadNode: holds a Download object as well as additional beatmap fields, and handles drawing.
- DownloadList: manages the current list of downloads.
- DownloadsMenu: game state controller.
- DownloadServer: interface for a beatmap download server.
- BloodcatServer: implements DownloadServer using Bloodcat.
- ReadableByteChannelWrapper: wrapper for ReadableByteChannel that tracks progress.

Added images:
- "downloads" image by @kouyang.
- "search-background" image from "Minimalist Miku" skin (listed in credits).
- "delete" icon by Visual Pharm (https://www.iconfinder.com/icons/27842/) under CC BY-ND 3.0.

Other changes:
- Added up/down/left/right Expand directions to MenuButton class.
- Removed width/height parameters from OsuParser (leftovers).

Signed-off-by: Jeffrey Han <itdelatrisu@gmail.com>
This commit is contained in:
Jeffrey Han 2015-02-01 02:10:17 -05:00
parent cb5a7d6a4b
commit 70c70fd812
21 changed files with 1701 additions and 22 deletions

View File

@ -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/). The download page is located [here](https://www.java.com/en/download/).
### Beatmaps ### 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! 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/). [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 If osu! is already installed, this application will attempt to load songs
directly from the osu! program folder. Otherwise, place songs in the generated directly from the osu! program folder. Otherwise, place songs in the generated

View File

@ -138,5 +138,10 @@
<artifactId>sqlite-jdbc</artifactId> <artifactId>sqlite-jdbc</artifactId>
<version>3.8.7</version> <version>3.8.7</version>
</dependency> </dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20140107</version>
</dependency>
</dependencies> </dependencies>
</project> </project>

BIN
res/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
res/downloads.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
res/search-background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@ -19,6 +19,7 @@
package itdelatrisu.opsu; package itdelatrisu.opsu;
import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.MusicController;
import itdelatrisu.opsu.downloads.DownloadList;
import org.newdawn.slick.AppGameContainer; import org.newdawn.slick.AppGameContainer;
import org.newdawn.slick.Game; import org.newdawn.slick.Game;
@ -110,4 +111,13 @@ public class Container extends AppGameContainer {
throw e; // re-throw exception 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();
}
} }

View File

@ -465,6 +465,26 @@ public enum GameImage {
return img.getScaledCopy((h * 0.15f) / img.getHeight()); 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) { HISTORY ("history", "png", false, false) {
@Override @Override
protected Image process_sub(Image img, int w, int h) { protected Image process_sub(Image img, int w, int h) {
@ -573,10 +593,7 @@ public enum GameImage {
* @param type the file types (separated by '|') * @param type the file types (separated by '|')
*/ */
GameImage(String filename, String type) { GameImage(String filename, String type) {
this.filename = filename; this(filename, type, true, true);
this.type = getType(type);
this.skinnable = true;
this.preload = true;
} }
/** /**
@ -586,11 +603,8 @@ public enum GameImage {
* @param type the file types (separated by '|') * @param type the file types (separated by '|')
*/ */
GameImage(String filename, String filenameFormat, String type) { GameImage(String filename, String filenameFormat, String type) {
this.filename = filename; this(filename, type, true, true);
this.filenameFormat = filenameFormat; this.filenameFormat = filenameFormat;
this.type = getType(type);
this.skinnable = true;
this.preload = true;
} }
/** /**

View File

@ -50,7 +50,7 @@ public class MenuButton {
private Expand dir = Expand.CENTER; private Expand dir = Expand.CENTER;
/** Scaled expansion directions (for hovering). */ /** 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. * Creates a new button from an Image.
@ -137,7 +137,7 @@ public class MenuButton {
public void draw() { public void draw() {
if (img != null) { if (img != null) {
if (imgL == null) { if (imgL == null) {
Image imgScaled = img.getScaledCopy(scale); Image imgScaled = (scale == 1f) ? img : img.getScaledCopy(scale);
imgScaled.setAlpha(img.getAlpha()); imgScaled.setAlpha(img.getAlpha());
imgScaled.draw(x - xRadius, y - yRadius); imgScaled.draw(x - xRadius, y - yRadius);
} else { } else {
@ -229,9 +229,13 @@ public class MenuButton {
// offset by difference between normal/scaled image dimensions // offset by difference between normal/scaled image dimensions
xOffset = (int) ((scale - 1f) * img.getWidth()); xOffset = (int) ((scale - 1f) * img.getWidth());
yOffset = (int) ((scale - 1f) * img.getHeight()); 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 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 yOffset *= -1; // flip y for down
} }
this.xRadius = ((img.getWidth() * scale) + xOffset) / 2f; this.xRadius = ((img.getWidth() * scale) + xOffset) / 2f;

View File

@ -19,6 +19,8 @@
package itdelatrisu.opsu; package itdelatrisu.opsu;
import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.MusicController;
import itdelatrisu.opsu.downloads.DownloadList;
import itdelatrisu.opsu.states.DownloadsMenu;
import itdelatrisu.opsu.states.Game; import itdelatrisu.opsu.states.Game;
import itdelatrisu.opsu.states.GamePauseMenu; import itdelatrisu.opsu.states.GamePauseMenu;
import itdelatrisu.opsu.states.GameRanking; import itdelatrisu.opsu.states.GameRanking;
@ -62,7 +64,8 @@ public class Opsu extends StateBasedGame {
STATE_GAME = 4, STATE_GAME = 4,
STATE_GAMEPAUSEMENU = 5, STATE_GAMEPAUSEMENU = 5,
STATE_GAMERANKING = 6, STATE_GAMERANKING = 6,
STATE_OPTIONSMENU = 7; STATE_OPTIONSMENU = 7,
STATE_DOWNLOADSMENU = 8;
/** Server socket for restricting the program to a single instance. */ /** Server socket for restricting the program to a single instance. */
private static ServerSocket SERVER_SOCKET; private static ServerSocket SERVER_SOCKET;
@ -85,6 +88,7 @@ public class Opsu extends StateBasedGame {
addState(new GamePauseMenu(STATE_GAMEPAUSEMENU)); addState(new GamePauseMenu(STATE_GAMEPAUSEMENU));
addState(new GameRanking(STATE_GAMERANKING)); addState(new GameRanking(STATE_GAMERANKING));
addState(new OptionsMenu(STATE_OPTIONSMENU)); addState(new OptionsMenu(STATE_OPTIONSMENU));
addState(new DownloadsMenu(STATE_DOWNLOADSMENU));
} }
/** /**
@ -194,6 +198,10 @@ public class Opsu extends StateBasedGame {
return false; return false;
} }
// show confirmation dialog if any downloads are active
if (DownloadList.get().hasActiveDownloads() && DownloadList.showExitConfirmation())
return false;
return true; return true;
} }
@ -204,6 +212,9 @@ public class Opsu extends StateBasedGame {
// close scores database // close scores database
ScoreDB.closeConnection(); ScoreDB.closeConnection();
// cancel all downloads
DownloadList.get().cancelAllDownloads();
// close server socket // close server socket
if (SERVER_SOCKET != null) { if (SERVER_SOCKET != null) {
try { try {

View File

@ -55,10 +55,8 @@ public class OsuParser {
/** /**
* Invokes parser for each OSU file in a root directory. * Invokes parser for each OSU file in a root directory.
* @param root the root directory (search has depth 1) * @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 // create a new OsuGroupList
OsuGroupList.create(); OsuGroupList.create();

View File

@ -20,6 +20,7 @@ package itdelatrisu.opsu;
import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundController;
import itdelatrisu.opsu.audio.SoundEffect; import itdelatrisu.opsu.audio.SoundEffect;
import itdelatrisu.opsu.downloads.DownloadNode;
import java.awt.Font; import java.awt.Font;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
@ -71,7 +72,8 @@ public class Utils {
COLOR_ORANGE_OBJECT = new Color(255, 200, 32), COLOR_ORANGE_OBJECT = new Color(255, 200, 32),
COLOR_YELLOW_ALPHA = new Color(255, 255, 0, 0.4f), COLOR_YELLOW_ALPHA = new Color(255, 255, 0, 0.4f),
COLOR_WHITE_FADE = new Color(255, 255, 255, 1f), 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. */ /** The default map colors, used when a map does not provide custom colors. */
public static final Color[] DEFAULT_COMBO = { public static final Color[] DEFAULT_COMBO = {
@ -201,6 +203,9 @@ public class Utils {
// initialize score data buttons // initialize score data buttons
ScoreData.init(width, height); ScoreData.init(width, height);
// initialize download nodes
DownloadNode.init(width, height);
// back button // back button
Image back = GameImage.MENU_BACK.getImage(); Image back = GameImage.MENU_BACK.getImage();
backButton = new MenuButton(back, 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);
}
} }

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<DownloadNode> nodes;
/** The map of beatmap set IDs to DownloadNodes for the current downloads. */
private Map<Integer, DownloadNode> map;
/**
* Returns the single instance of this class.
*/
public static DownloadList get() { return list; }
/**
* Constructor.
*/
private DownloadList() {
nodes = new ArrayList<DownloadNode>();
map = new HashMap<Integer, DownloadNode>();
}
/**
* 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);
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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();
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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; }
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}
}
}
}

View File

@ -21,6 +21,7 @@ package itdelatrisu.opsu.states;
import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.ErrorHandler;
import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.GameImage;
import itdelatrisu.opsu.MenuButton; import itdelatrisu.opsu.MenuButton;
import itdelatrisu.opsu.MenuButton.Expand;
import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Opsu;
import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Options;
import itdelatrisu.opsu.OsuFile; import itdelatrisu.opsu.OsuFile;
@ -75,6 +76,9 @@ public class MainMenu extends BasicGameState {
/** Music control buttons. */ /** Music control buttons. */
private MenuButton musicPlay, musicPause, musicNext, musicPrevious; private MenuButton musicPlay, musicPause, musicNext, musicPrevious;
/** Button linking to Downloads menu. */
private MenuButton downloadsButton;
/** Button linking to repository. */ /** Button linking to repository. */
private MenuButton repoButton; private MenuButton repoButton;
@ -138,6 +142,12 @@ public class MainMenu extends BasicGameState {
musicNext.setHoverScale(1.5f); musicNext.setHoverScale(1.5f);
musicPrevious.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 // initialize repository button
if (Desktop.isDesktopSupported()) { // only if a webpage can be opened if (Desktop.isDesktopSupported()) { // only if a webpage can be opened
Image repoImg = GameImage.REPOSITORY.getImage(); Image repoImg = GameImage.REPOSITORY.getImage();
@ -173,6 +183,9 @@ public class MainMenu extends BasicGameState {
g.fillRect(0, height * 8 / 9f, width, height / 9f); g.fillRect(0, height * 8 / 9f, width, height / 9f);
Utils.COLOR_BLACK_ALPHA.a = oldAlpha; Utils.COLOR_BLACK_ALPHA.a = oldAlpha;
// draw downloads button
downloadsButton.draw();
// draw buttons // draw buttons
if (logoTimer > 0) { if (logoTimer > 0) {
playButton.draw(); playButton.draw();
@ -239,6 +252,7 @@ public class MainMenu extends BasicGameState {
exitButton.hoverUpdate(delta, mouseX, mouseY); exitButton.hoverUpdate(delta, mouseX, mouseY);
if (repoButton != null) if (repoButton != null)
repoButton.hoverUpdate(delta, mouseX, mouseY); repoButton.hoverUpdate(delta, mouseX, mouseY);
downloadsButton.hoverUpdate(delta, mouseX, mouseY);
musicPlay.hoverUpdate(delta, mouseX, mouseY); musicPlay.hoverUpdate(delta, mouseX, mouseY);
musicPause.hoverUpdate(delta, mouseX, mouseY); musicPause.hoverUpdate(delta, mouseX, mouseY);
if (musicPlay.contains(mouseX, mouseY)) if (musicPlay.contains(mouseX, mouseY))
@ -319,6 +333,8 @@ public class MainMenu extends BasicGameState {
musicPrevious.setScale(1f); musicPrevious.setScale(1f);
if (repoButton != null && !repoButton.contains(mouseX, mouseY)) if (repoButton != null && !repoButton.contains(mouseX, mouseY))
repoButton.setScale(1f); repoButton.setScale(1f);
if (!downloadsButton.contains(mouseX, mouseY))
downloadsButton.setScale(1f);
} }
@Override @Override
@ -355,6 +371,12 @@ public class MainMenu extends BasicGameState {
MusicController.setPosition(0); 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 // repository button actions
else if (repoButton != null && repoButton.contains(x, y)) { else if (repoButton != null && repoButton.contains(x, y)) {
try { try {
@ -437,5 +459,6 @@ public class MainMenu extends BasicGameState {
musicPause.setScale(1f); musicPause.setScale(1f);
musicNext.setScale(1f); musicNext.setScale(1f);
musicPrevious.setScale(1f); musicPrevious.setScale(1f);
downloadsButton.setScale(1f);
} }
} }

View File

@ -620,7 +620,7 @@ public class SongMenu extends BasicGameState {
// invoke unpacker and parser // invoke unpacker and parser
File beatmapDir = Options.getBeatmapDir(); File beatmapDir = Options.getBeatmapDir();
OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir);
OsuParser.parseAllFiles(beatmapDir, container.getWidth(), container.getHeight()); OsuParser.parseAllFiles(beatmapDir);
// initialize song list // initialize song list
if (OsuGroupList.get().size() > 0) { if (OsuGroupList.get().size() > 0) {

View File

@ -93,8 +93,6 @@ public class Splash extends BasicGameState {
finished = true; finished = true;
} else { } else {
// load resources in a new thread // load resources in a new thread
final int width = container.getWidth();
final int height = container.getHeight();
thread = new Thread() { thread = new Thread() {
@Override @Override
public void run() { public void run() {
@ -104,7 +102,7 @@ public class Splash extends BasicGameState {
OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir);
// parse song directory // parse song directory
OsuParser.parseAllFiles(beatmapDir, width, height); OsuParser.parseAllFiles(beatmapDir);
// load sounds // load sounds
SoundController.init(); SoundController.init();