diff --git a/src/itdelatrisu/opsu/audio/MultiClip.java b/src/itdelatrisu/opsu/audio/MultiClip.java index 448c0a35..0d4da1c6 100644 --- a/src/itdelatrisu/opsu/audio/MultiClip.java +++ b/src/itdelatrisu/opsu/audio/MultiClip.java @@ -1,5 +1,7 @@ package itdelatrisu.opsu.audio; +import itdelatrisu.opsu.ErrorHandler; + import java.io.IOException; import java.util.Iterator; import java.util.LinkedList; @@ -9,6 +11,7 @@ import javax.sound.sampled.AudioInputStream; import javax.sound.sampled.AudioSystem; import javax.sound.sampled.Clip; import javax.sound.sampled.FloatControl; +import javax.sound.sampled.LineListener; import javax.sound.sampled.LineUnavailableException; /** @@ -36,6 +39,9 @@ public class MultiClip { /** A list of clips used for this audio sample. */ private LinkedList clips = new LinkedList(); + /** The audio input stream. */ + private AudioInputStream audioIn; + /** The format of this audio sample. */ private AudioFormat format; @@ -52,6 +58,7 @@ public class MultiClip { */ public MultiClip(String name, AudioInputStream audioIn) throws IOException, LineUnavailableException { this.name = name; + this.audioIn = audioIn; if (audioIn != null) { format = audioIn.getFormat(); @@ -97,8 +104,9 @@ public class MultiClip { /** * Plays the clip with the specified volume. * @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(); if (clip == null) return; @@ -111,6 +119,8 @@ public class MultiClip { gainControl.setValue(dB); } + if (listener != null) + clip.addLineListener(listener); clip.setFramePosition(0); clip.start(); } @@ -159,6 +169,29 @@ public class MultiClip { 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(); + } + 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. */ diff --git a/src/itdelatrisu/opsu/audio/MusicController.java b/src/itdelatrisu/opsu/audio/MusicController.java index 3a57437b..e81c2ea6 100644 --- a/src/itdelatrisu/opsu/audio/MusicController.java +++ b/src/itdelatrisu/opsu/audio/MusicController.java @@ -54,7 +54,7 @@ public class MusicController { private static OsuFile lastOsu; /** The track duration. */ - private static int duration = -1; + private static int duration = 0; /** Thread for loading tracks. */ private static Thread trackLoader; @@ -273,7 +273,7 @@ public class MusicController { if (!trackExists() || lastOsu == null) return -1; - if (duration == -1) { + if (duration == 0) { if (lastOsu.audioFilename.getName().endsWith(".mp3")) { try { AudioFileFormat fileFormat = AudioSystem.getAudioFileFormat(lastOsu.audioFilename); @@ -383,7 +383,7 @@ public class MusicController { // reset state lastOsu = null; - duration = -1; + duration = 0; trackEnded = false; themePlaying = false; pauseTime = 0f; diff --git a/src/itdelatrisu/opsu/audio/SoundController.java b/src/itdelatrisu/opsu/audio/SoundController.java index d4dcb2f4..5976fce5 100644 --- a/src/itdelatrisu/opsu/audio/SoundController.java +++ b/src/itdelatrisu/opsu/audio/SoundController.java @@ -33,9 +33,11 @@ import javax.sound.sampled.AudioInputStream; import javax.sound.sampled.AudioSystem; import javax.sound.sampled.Clip; import javax.sound.sampled.DataLine; +import javax.sound.sampled.LineListener; import javax.sound.sampled.LineUnavailableException; import javax.sound.sampled.UnsupportedAudioFileException; +import org.newdawn.slick.SlickException; import org.newdawn.slick.util.ResourceLoader; /** @@ -52,6 +54,9 @@ public class SoundController { public MultiClip getClip(); } + /** The current track being played, if any. */ + private static MultiClip currentTrack; + /** Sample volume multiplier, from timing points [0, 1]. */ private static float sampleVolumeMultiplier = 1f; @@ -83,9 +88,22 @@ public class SoundController { in.close(); 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(); if (isMP3) { AudioFormat decodedFormat = new AudioFormat( @@ -151,7 +169,7 @@ public class SoundController { // still couldn't find anything, try the default clip format 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); } return null; @@ -226,14 +244,15 @@ public class SoundController { * Plays a sound clip. * @param clip the Clip to play * @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 return; if (volume > 0f) { try { - clip.start(volume); + clip.start(volume, listener); } catch (LineUnavailableException e) { 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 */ 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 HitSound.setSampleSet(sampleSet); - playClip(HitSound.NORMAL.getClip(), volume); + playClip(HitSound.NORMAL.getClip(), volume, null); HitSound.setSampleSet(additionSampleSet); if ((hitSound & OsuHitObject.SOUND_WHISTLE) > 0) - playClip(HitSound.WHISTLE.getClip(), volume); + playClip(HitSound.WHISTLE.getClip(), volume, null); if ((hitSound & OsuHitObject.SOUND_FINISH) > 0) - playClip(HitSound.FINISH.getClip(), volume); + playClip(HitSound.FINISH.getClip(), volume, null); 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 */ 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)); } + + /** + * 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; + } + } } diff --git a/src/itdelatrisu/opsu/downloads/BloodcatServer.java b/src/itdelatrisu/opsu/downloads/BloodcatServer.java index bb1cf6f5..111cc77a 100644 --- a/src/itdelatrisu/opsu/downloads/BloodcatServer.java +++ b/src/itdelatrisu/opsu/downloads/BloodcatServer.java @@ -34,7 +34,7 @@ import org.json.JSONObject; /** * Download server: http://bloodcat.com/osu/ */ -public class BloodcatServer implements DownloadServer { +public class BloodcatServer extends DownloadServer { /** Formatted download URL: {@code beatmapSetID} */ private static final String DOWNLOAD_URL = "http://bloodcat.com/osu/s/%d"; diff --git a/src/itdelatrisu/opsu/downloads/DownloadNode.java b/src/itdelatrisu/opsu/downloads/DownloadNode.java index fd41231a..4a44d9c6 100644 --- a/src/itdelatrisu/opsu/downloads/DownloadNode.java +++ b/src/itdelatrisu/opsu/downloads/DownloadNode.java @@ -123,6 +123,21 @@ public class DownloadNode { (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 * download result button area. @@ -283,9 +298,10 @@ public class DownloadNode { * @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 + * @param previewing true if the beatmap is currently being previewed */ - public void drawResult(Graphics g, int index, boolean hover, boolean focus) { - float textX = buttonBaseX + buttonWidth * 0.02f; + public void drawResult(Graphics g, int index, boolean hover, boolean focus, boolean previewing) { + float textX = buttonBaseX + buttonWidth * 0.001f; float edgeX = buttonBaseX + buttonWidth * 0.985f; float y = buttonBaseY + index * buttonOffset; 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 Utils.FONT_BOLD.drawString( textX, y + marginY, diff --git a/src/itdelatrisu/opsu/downloads/DownloadServer.java b/src/itdelatrisu/opsu/downloads/DownloadServer.java index 995185da..b3840364 100644 --- a/src/itdelatrisu/opsu/downloads/DownloadServer.java +++ b/src/itdelatrisu/opsu/downloads/DownloadServer.java @@ -21,15 +21,18 @@ package itdelatrisu.opsu.downloads; 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. * @param beatmapSetID the beatmap set ID * @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 @@ -40,7 +43,7 @@ public interface DownloadServer { * @return the result array * @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. @@ -48,5 +51,14 @@ public interface DownloadServer { * {@link #resultList(String, int, boolean)} if multiple pages exist. * @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); + } } diff --git a/src/itdelatrisu/opsu/states/DownloadsMenu.java b/src/itdelatrisu/opsu/states/DownloadsMenu.java index 72c6aa2d..4223a230 100644 --- a/src/itdelatrisu/opsu/states/DownloadsMenu.java +++ b/src/itdelatrisu/opsu/states/DownloadsMenu.java @@ -39,6 +39,11 @@ import itdelatrisu.opsu.downloads.DownloadServer; import java.io.File; 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.GameContainer; @@ -51,6 +56,7 @@ 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; +import org.newdawn.slick.util.Log; /** * Downloads menu. @@ -137,6 +143,9 @@ public class DownloadsMenu extends BasicGameState { /** Beatmap importing thread. */ private Thread importThread; + /** Beatmap set ID of the current beatmap being previewed, or -1 if none. */ + private int previewID = -1; + // game-related variables private GameContainer container; private StateBasedGame game; @@ -248,7 +257,8 @@ public class DownloadsMenu extends BasicGameState { int index = startResult + i; if (index >= nodes.length) 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 @@ -441,10 +451,61 @@ public class DownloadsMenu extends BasicGameState { if (index >= nodes.length) break; if (DownloadNode.resultContains(x, y, i)) { - DownloadNode node = nodes[index]; + final DownloadNode node = nodes[index]; // 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; SoundController.playSound(SoundEffect.MENUCLICK); @@ -677,12 +738,15 @@ public class DownloadsMenu extends BasicGameState { startResult = 0; startDownloadIndex = 0; pageDir = Page.RESET; + previewID = -1; } @Override public void leave(GameContainer container, StateBasedGame game) throws SlickException { search.setFocus(false); + SoundController.stopTrack(); + MusicController.resume(); } /** diff --git a/src/itdelatrisu/opsu/states/Game.java b/src/itdelatrisu/opsu/states/Game.java index 517b4217..6bc86753 100644 --- a/src/itdelatrisu/opsu/states/Game.java +++ b/src/itdelatrisu/opsu/states/Game.java @@ -1099,9 +1099,8 @@ public class Game extends BasicGameState { UI.hideCursor(); // replays - if (isReplay) { + if (isReplay) GameMod.loadModState(previousMods); - } } /**