Added track previews to the downloads menu.

Currently uses the osu! server to load MP3 previews.

Signed-off-by: Jeffrey Han <itdelatrisu@gmail.com>
This commit is contained in:
Jeffrey Han 2015-03-19 15:58:50 -04:00
parent 7d5899ba7e
commit ec042159a8
8 changed files with 207 additions and 28 deletions

View File

@ -1,5 +1,7 @@
package itdelatrisu.opsu.audio; package itdelatrisu.opsu.audio;
import itdelatrisu.opsu.ErrorHandler;
import java.io.IOException; import java.io.IOException;
import java.util.Iterator; import java.util.Iterator;
import java.util.LinkedList; import java.util.LinkedList;
@ -9,6 +11,7 @@ import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem; import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Clip; import javax.sound.sampled.Clip;
import javax.sound.sampled.FloatControl; import javax.sound.sampled.FloatControl;
import javax.sound.sampled.LineListener;
import javax.sound.sampled.LineUnavailableException; import javax.sound.sampled.LineUnavailableException;
/** /**
@ -36,6 +39,9 @@ public class MultiClip {
/** A list of clips used for this audio sample. */ /** A list of clips used for this audio sample. */
private LinkedList<Clip> clips = new LinkedList<Clip>(); private LinkedList<Clip> clips = new LinkedList<Clip>();
/** The audio input stream. */
private AudioInputStream audioIn;
/** The format of this audio sample. */ /** The format of this audio sample. */
private AudioFormat format; private AudioFormat format;
@ -52,6 +58,7 @@ public class MultiClip {
*/ */
public MultiClip(String name, AudioInputStream audioIn) throws IOException, LineUnavailableException { public MultiClip(String name, AudioInputStream audioIn) throws IOException, LineUnavailableException {
this.name = name; this.name = name;
this.audioIn = audioIn;
if (audioIn != null) { if (audioIn != null) {
format = audioIn.getFormat(); format = audioIn.getFormat();
@ -97,8 +104,9 @@ public class MultiClip {
/** /**
* Plays the clip with the specified volume. * Plays the clip with the specified volume.
* @param volume the volume the play at * @param volume the volume the play at
* @param listener the line listener
*/ */
public void start(float volume) throws LineUnavailableException { public void start(float volume, LineListener listener) throws LineUnavailableException {
Clip clip = getClip(); Clip clip = getClip();
if (clip == null) if (clip == null)
return; return;
@ -111,6 +119,8 @@ public class MultiClip {
gainControl.setValue(dB); gainControl.setValue(dB);
} }
if (listener != null)
clip.addLineListener(listener);
clip.setFramePosition(0); clip.setFramePosition(0);
clip.start(); clip.start();
} }
@ -159,6 +169,29 @@ public class MultiClip {
return c; return c;
} }
/**
* Destroys the MultiClip and releases all resources.
*/
public void destroy() {
if (clips.size() > 0) {
for (Clip c : clips) {
c.stop();
c.flush();
c.close();
}
extraClips -= clips.size() - 1;
clips = new LinkedList<Clip>();
}
audioData = null;
if (audioIn != null) {
try {
audioIn.close();
} catch (IOException e) {
ErrorHandler.error(String.format("Could not close AudioInputStream for MultiClip %s.", name), e, true);
}
}
}
/** /**
* Destroys all extra clips. * Destroys all extra clips.
*/ */

View File

@ -54,7 +54,7 @@ public class MusicController {
private static OsuFile lastOsu; private static OsuFile lastOsu;
/** The track duration. */ /** The track duration. */
private static int duration = -1; private static int duration = 0;
/** Thread for loading tracks. */ /** Thread for loading tracks. */
private static Thread trackLoader; private static Thread trackLoader;
@ -273,7 +273,7 @@ public class MusicController {
if (!trackExists() || lastOsu == null) if (!trackExists() || lastOsu == null)
return -1; return -1;
if (duration == -1) { if (duration == 0) {
if (lastOsu.audioFilename.getName().endsWith(".mp3")) { if (lastOsu.audioFilename.getName().endsWith(".mp3")) {
try { try {
AudioFileFormat fileFormat = AudioSystem.getAudioFileFormat(lastOsu.audioFilename); AudioFileFormat fileFormat = AudioSystem.getAudioFileFormat(lastOsu.audioFilename);
@ -383,7 +383,7 @@ public class MusicController {
// reset state // reset state
lastOsu = null; lastOsu = null;
duration = -1; duration = 0;
trackEnded = false; trackEnded = false;
themePlaying = false; themePlaying = false;
pauseTime = 0f; pauseTime = 0f;

View File

@ -33,9 +33,11 @@ import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem; import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Clip; import javax.sound.sampled.Clip;
import javax.sound.sampled.DataLine; import javax.sound.sampled.DataLine;
import javax.sound.sampled.LineListener;
import javax.sound.sampled.LineUnavailableException; import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.UnsupportedAudioFileException; import javax.sound.sampled.UnsupportedAudioFileException;
import org.newdawn.slick.SlickException;
import org.newdawn.slick.util.ResourceLoader; import org.newdawn.slick.util.ResourceLoader;
/** /**
@ -52,6 +54,9 @@ public class SoundController {
public MultiClip getClip(); public MultiClip getClip();
} }
/** The current track being played, if any. */
private static MultiClip currentTrack;
/** Sample volume multiplier, from timing points [0, 1]. */ /** Sample volume multiplier, from timing points [0, 1]. */
private static float sampleVolumeMultiplier = 1f; private static float sampleVolumeMultiplier = 1f;
@ -83,9 +88,22 @@ public class SoundController {
in.close(); in.close();
AudioInputStream audioIn = AudioSystem.getAudioInputStream(url); AudioInputStream audioIn = AudioSystem.getAudioInputStream(url);
return loadClip(ref, audioIn, isMP3);
} catch (UnsupportedAudioFileException | IOException | LineUnavailableException | RuntimeException e) {
ErrorHandler.error(String.format("Failed to load file '%s'.", ref), e, true);
return null;
}
}
// GNU/Linux workaround /**
// Clip clip = AudioSystem.getClip(); * Loads and returns a Clip from an audio input stream.
* @param ref the resource name
* @param audioIn the audio input stream
* @param isMP3 true if MP3, false if WAV
* @return the loaded and opened clip
*/
private static MultiClip loadClip(String ref, AudioInputStream audioIn, boolean isMP3) {
try {
AudioFormat format = audioIn.getFormat(); AudioFormat format = audioIn.getFormat();
if (isMP3) { if (isMP3) {
AudioFormat decodedFormat = new AudioFormat( AudioFormat decodedFormat = new AudioFormat(
@ -151,7 +169,7 @@ public class SoundController {
// still couldn't find anything, try the default clip format // still couldn't find anything, try the default clip format
return new MultiClip(ref, AudioSystem.getAudioInputStream(clip.getFormat(), audioIn)); return new MultiClip(ref, AudioSystem.getAudioInputStream(clip.getFormat(), audioIn));
} }
} catch (UnsupportedAudioFileException | IOException | LineUnavailableException | RuntimeException e) { } catch (IOException | LineUnavailableException | RuntimeException e) {
ErrorHandler.error(String.format("Failed to load file '%s'.", ref), e, true); ErrorHandler.error(String.format("Failed to load file '%s'.", ref), e, true);
} }
return null; return null;
@ -226,14 +244,15 @@ public class SoundController {
* Plays a sound clip. * Plays a sound clip.
* @param clip the Clip to play * @param clip the Clip to play
* @param volume the volume [0, 1] * @param volume the volume [0, 1]
* @param listener the line listener
*/ */
private static void playClip(MultiClip clip, float volume) { private static void playClip(MultiClip clip, float volume, LineListener listener) {
if (clip == null) // clip failed to load properly if (clip == null) // clip failed to load properly
return; return;
if (volume > 0f) { if (volume > 0f) {
try { try {
clip.start(volume); clip.start(volume, listener);
} catch (LineUnavailableException e) { } catch (LineUnavailableException e) {
ErrorHandler.error(String.format("Could not start a clip '%s'.", clip.getName()), e, true); ErrorHandler.error(String.format("Could not start a clip '%s'.", clip.getName()), e, true);
} }
@ -245,7 +264,7 @@ public class SoundController {
* @param s the sound effect * @param s the sound effect
*/ */
public static void playSound(SoundComponent s) { public static void playSound(SoundComponent s) {
playClip(s.getClip(), Options.getEffectVolume() * Options.getMasterVolume()); playClip(s.getClip(), Options.getEffectVolume() * Options.getMasterVolume(), null);
} }
/** /**
@ -264,15 +283,15 @@ public class SoundController {
// play all sounds // play all sounds
HitSound.setSampleSet(sampleSet); HitSound.setSampleSet(sampleSet);
playClip(HitSound.NORMAL.getClip(), volume); playClip(HitSound.NORMAL.getClip(), volume, null);
HitSound.setSampleSet(additionSampleSet); HitSound.setSampleSet(additionSampleSet);
if ((hitSound & OsuHitObject.SOUND_WHISTLE) > 0) if ((hitSound & OsuHitObject.SOUND_WHISTLE) > 0)
playClip(HitSound.WHISTLE.getClip(), volume); playClip(HitSound.WHISTLE.getClip(), volume, null);
if ((hitSound & OsuHitObject.SOUND_FINISH) > 0) if ((hitSound & OsuHitObject.SOUND_FINISH) > 0)
playClip(HitSound.FINISH.getClip(), volume); playClip(HitSound.FINISH.getClip(), volume, null);
if ((hitSound & OsuHitObject.SOUND_CLAP) > 0) if ((hitSound & OsuHitObject.SOUND_CLAP) > 0)
playClip(HitSound.CLAP.getClip(), volume); playClip(HitSound.CLAP.getClip(), volume, null);
} }
/** /**
@ -280,7 +299,7 @@ public class SoundController {
* @param s the hit sound * @param s the hit sound
*/ */
public static void playHitSound(SoundComponent s) { public static void playHitSound(SoundComponent s) {
playClip(s.getClip(), Options.getHitSoundVolume() * sampleVolumeMultiplier * Options.getMasterVolume()); playClip(s.getClip(), Options.getHitSoundVolume() * sampleVolumeMultiplier * Options.getMasterVolume(), null);
} }
/** /**
@ -300,4 +319,35 @@ public class SoundController {
return currentFileIndex * 100 / (SoundEffect.SIZE + (HitSound.SIZE * SampleSet.SIZE)); return currentFileIndex * 100 / (SoundEffect.SIZE + (HitSound.SIZE * SampleSet.SIZE));
} }
/**
* Plays a track from a URL.
* If a track is currently playing, it will be stopped.
* @param url the resource URL
* @param isMP3 true if MP3, false if WAV
* @param listener the line listener
* @return the MultiClip being played
* @throws SlickException if any error occurred (UnsupportedAudioFileException, IOException, RuntimeException)
*/
public static synchronized MultiClip playTrack(URL url, boolean isMP3, LineListener listener) throws SlickException {
stopTrack();
try {
AudioInputStream audioIn = AudioSystem.getAudioInputStream(url);
currentTrack = loadClip(url.getFile(), audioIn, isMP3);
playClip(currentTrack, Options.getMusicVolume() * Options.getMasterVolume(), listener);
return currentTrack;
} catch (UnsupportedAudioFileException | IOException | RuntimeException e) {
throw new SlickException(String.format("Failed to load clip '%s'.", url.getFile(), e));
}
}
/**
* Stops the current track playing, if any.
*/
public static synchronized void stopTrack() {
if (currentTrack != null) {
currentTrack.destroy();
currentTrack = null;
}
}
} }

View File

@ -34,7 +34,7 @@ import org.json.JSONObject;
/** /**
* Download server: http://bloodcat.com/osu/ * Download server: http://bloodcat.com/osu/
*/ */
public class BloodcatServer implements DownloadServer { public class BloodcatServer extends DownloadServer {
/** Formatted download URL: {@code beatmapSetID} */ /** Formatted download URL: {@code beatmapSetID} */
private static final String DOWNLOAD_URL = "http://bloodcat.com/osu/s/%d"; private static final String DOWNLOAD_URL = "http://bloodcat.com/osu/s/%d";

View File

@ -123,6 +123,21 @@ public class DownloadNode {
(cy > y && cy < y + buttonHeight)); (cy > y && cy < y + buttonHeight));
} }
/**
* Returns true if the coordinates are within the bounds of the
* download result 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 resultIconContains(float cx, float cy, int index) {
int iconWidth = GameImage.MUSIC_PLAY.getImage().getWidth();
float x = buttonBaseX + buttonWidth * 0.001f;
float y = buttonBaseY + (index * buttonOffset) + buttonHeight / 2f;
return ((cx > x && cx < x + iconWidth) &&
(cy > y - iconWidth / 2 && cy < y + iconWidth / 2));
}
/** /**
* Returns true if the coordinates are within the bounds of the * Returns true if the coordinates are within the bounds of the
* download result button area. * download result button area.
@ -283,9 +298,10 @@ public class DownloadNode {
* @param index the index (to offset the button from the topmost button) * @param index the index (to offset the button from the topmost button)
* @param hover true if the mouse is hovering over this button * @param hover true if the mouse is hovering over this button
* @param focus true if the button is focused * @param focus true if the button is focused
* @param previewing true if the beatmap is currently being previewed
*/ */
public void drawResult(Graphics g, int index, boolean hover, boolean focus) { public void drawResult(Graphics g, int index, boolean hover, boolean focus, boolean previewing) {
float textX = buttonBaseX + buttonWidth * 0.02f; float textX = buttonBaseX + buttonWidth * 0.001f;
float edgeX = buttonBaseX + buttonWidth * 0.985f; float edgeX = buttonBaseX + buttonWidth * 0.985f;
float y = buttonBaseY + index * buttonOffset; float y = buttonBaseY + index * buttonOffset;
float marginY = buttonHeight * 0.04f; float marginY = buttonHeight * 0.04f;
@ -310,6 +326,11 @@ public class DownloadNode {
} }
} }
// preview button
Image img = (previewing) ? GameImage.MUSIC_PAUSE.getImage() : GameImage.MUSIC_PLAY.getImage();
img.drawCentered(textX + img.getWidth() / 2, y + buttonHeight / 2f);
textX += img.getWidth() + buttonWidth * 0.001f;
// text // text
Utils.FONT_BOLD.drawString( Utils.FONT_BOLD.drawString(
textX, y + marginY, textX, y + marginY,

View File

@ -21,15 +21,18 @@ package itdelatrisu.opsu.downloads;
import java.io.IOException; import java.io.IOException;
/** /**
* Interface for beatmap download servers. * Abstract class for beatmap download servers.
*/ */
public interface DownloadServer { public abstract class DownloadServer {
/** Track preview URL. */
private static final String PREVIEW_URL = "http://b.ppy.sh/preview/%d.mp3";
/** /**
* Returns a web address to download the given beatmap. * Returns a web address to download the given beatmap.
* @param beatmapSetID the beatmap set ID * @param beatmapSetID the beatmap set ID
* @return the URL string * @return the URL string
*/ */
public String getURL(int beatmapSetID); public abstract String getURL(int beatmapSetID);
/** /**
* Returns a list of results for a given search query, or null if the * Returns a list of results for a given search query, or null if the
@ -40,7 +43,7 @@ public interface DownloadServer {
* @return the result array * @return the result array
* @throws IOException if any connection problem occurs * @throws IOException if any connection problem occurs
*/ */
public DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException; public abstract DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException;
/** /**
* Returns the total number of results for the last search query. * Returns the total number of results for the last search query.
@ -48,5 +51,14 @@ public interface DownloadServer {
* {@link #resultList(String, int, boolean)} if multiple pages exist. * {@link #resultList(String, int, boolean)} if multiple pages exist.
* @return the result count, or -1 if no query * @return the result count, or -1 if no query
*/ */
public int totalResults(); public abstract int totalResults();
/**
* Returns a web address to preview the given beatmap.
* @param beatmapSetID the beatmap set ID
* @return the URL string
*/
public String getPreviewURL(int beatmapSetID) {
return String.format(PREVIEW_URL, beatmapSetID);
}
} }

View File

@ -39,6 +39,11 @@ import itdelatrisu.opsu.downloads.DownloadServer;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import javax.sound.sampled.LineEvent;
import javax.sound.sampled.LineListener;
import org.newdawn.slick.Color; import org.newdawn.slick.Color;
import org.newdawn.slick.GameContainer; import org.newdawn.slick.GameContainer;
@ -51,6 +56,7 @@ import org.newdawn.slick.state.BasicGameState;
import org.newdawn.slick.state.StateBasedGame; import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.FadeInTransition; import org.newdawn.slick.state.transition.FadeInTransition;
import org.newdawn.slick.state.transition.FadeOutTransition; import org.newdawn.slick.state.transition.FadeOutTransition;
import org.newdawn.slick.util.Log;
/** /**
* Downloads menu. * Downloads menu.
@ -137,6 +143,9 @@ public class DownloadsMenu extends BasicGameState {
/** Beatmap importing thread. */ /** Beatmap importing thread. */
private Thread importThread; private Thread importThread;
/** Beatmap set ID of the current beatmap being previewed, or -1 if none. */
private int previewID = -1;
// game-related variables // game-related variables
private GameContainer container; private GameContainer container;
private StateBasedGame game; private StateBasedGame game;
@ -248,7 +257,8 @@ public class DownloadsMenu extends BasicGameState {
int index = startResult + i; int index = startResult + i;
if (index >= nodes.length) if (index >= nodes.length)
break; break;
nodes[index].drawResult(g, i, DownloadNode.resultContains(mouseX, mouseY, i), (index == focusResult)); nodes[index].drawResult(g, i, DownloadNode.resultContains(mouseX, mouseY, i),
(index == focusResult), (previewID == nodes[index].getID()));
} }
// scroll bar // scroll bar
@ -441,10 +451,61 @@ public class DownloadsMenu extends BasicGameState {
if (index >= nodes.length) if (index >= nodes.length)
break; break;
if (DownloadNode.resultContains(x, y, i)) { if (DownloadNode.resultContains(x, y, i)) {
DownloadNode node = nodes[index]; final DownloadNode node = nodes[index];
// check if map is already loaded // check if map is already loaded
if (OsuGroupList.get().containsBeatmapSetID(node.getID())) boolean isLoaded = OsuGroupList.get().containsBeatmapSetID(node.getID());
// track preview
if (DownloadNode.resultIconContains(x, y, i)) {
// set focus
if (!isLoaded) {
SoundController.playSound(SoundEffect.MENUCLICK);
focusResult = index;
focusTimer = FOCUS_DELAY;
}
if (previewID == node.getID()) {
// stop preview
previewID = -1;
SoundController.stopTrack();
} else {
// play preview
try {
final URL url = new URL(server.getPreviewURL(node.getID()));
MusicController.pause();
new Thread() {
@Override
public void run() {
try {
previewID = -1;
SoundController.playTrack(url, true, new LineListener() {
@Override
public void update(LineEvent event) {
if (event.getType() == LineEvent.Type.STOP) {
if (previewID != -1) {
SoundController.stopTrack();
previewID = -1;
}
}
}
});
previewID = node.getID();
} catch (SlickException e) {
UI.sendBarNotification("Failed to load track preview.");
Log.error(e);
}
}
}.start();
} catch (MalformedURLException e) {
UI.sendBarNotification("Could not load track preview (bad URL).");
Log.error(e);
}
}
return;
}
if (isLoaded)
return; return;
SoundController.playSound(SoundEffect.MENUCLICK); SoundController.playSound(SoundEffect.MENUCLICK);
@ -677,12 +738,15 @@ public class DownloadsMenu extends BasicGameState {
startResult = 0; startResult = 0;
startDownloadIndex = 0; startDownloadIndex = 0;
pageDir = Page.RESET; pageDir = Page.RESET;
previewID = -1;
} }
@Override @Override
public void leave(GameContainer container, StateBasedGame game) public void leave(GameContainer container, StateBasedGame game)
throws SlickException { throws SlickException {
search.setFocus(false); search.setFocus(false);
SoundController.stopTrack();
MusicController.resume();
} }
/** /**

View File

@ -1099,10 +1099,9 @@ public class Game extends BasicGameState {
UI.hideCursor(); UI.hideCursor();
// replays // replays
if (isReplay) { if (isReplay)
GameMod.loadModState(previousMods); GameMod.loadModState(previousMods);
} }
}
/** /**
* Draws hit objects, hit results, and follow points. * Draws hit objects, hit results, and follow points.