diff --git a/src/itdelatrisu/opsu/Container.java b/src/itdelatrisu/opsu/Container.java index 6fa3fc7e..e9ef3f5c 100644 --- a/src/itdelatrisu/opsu/Container.java +++ b/src/itdelatrisu/opsu/Container.java @@ -21,6 +21,7 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.beatmap.Beatmap; import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapWatchService; import itdelatrisu.opsu.downloads.DownloadList; import itdelatrisu.opsu.downloads.Updater; import itdelatrisu.opsu.render.CurveRenderState; @@ -136,6 +137,11 @@ public class Container extends AppGameContainer { // delete OpenGL objects involved in the Curve rendering CurveRenderState.shutdown(); + + // destroy watch service + if (!Options.isWatchServiceEnabled()) + BeatmapWatchService.destroy(); + BeatmapWatchService.removeListeners(); } @Override diff --git a/src/itdelatrisu/opsu/Options.java b/src/itdelatrisu/opsu/Options.java index 9d824f26..e9fdbb56 100644 --- a/src/itdelatrisu/opsu/Options.java +++ b/src/itdelatrisu/opsu/Options.java @@ -456,7 +456,8 @@ public class Options { }, ENABLE_THEME_SONG ("Enable Theme Song", "MenuMusic", "Whether to play the theme song upon starting opsu!", true), REPLAY_SEEKING ("Replay Seeking", "ReplaySeeking", "Enable a seeking bar on the left side of the screen during replays.", false), - DISABLE_UPDATER ("Disable Automatic Updates", "DisableUpdater", "Disable automatic checking for updates upon starting opsu!.", false); + DISABLE_UPDATER ("Disable Automatic Updates", "DisableUpdater", "Disable automatic checking for updates upon starting opsu!.", false), + ENABLE_WATCH_SERVICE ("Enable Watch Service", "WatchService", "Watch the beatmap directory for changes. Requires a restart.", false); /** Option name. */ private final String name; @@ -973,6 +974,12 @@ public class Options { */ public static boolean isUpdaterDisabled() { return GameOption.DISABLE_UPDATER.getBooleanValue(); } + /** + * Returns whether or not the beatmap watch service is enabled. + * @return true if enabled + */ + public static boolean isWatchServiceEnabled() { return GameOption.ENABLE_WATCH_SERVICE.getBooleanValue(); } + /** * Sets the track checkpoint time, if within bounds. * @param time the track position (in ms) diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapParser.java b/src/itdelatrisu/opsu/beatmap/BeatmapParser.java index be729980..103ac6e2 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapParser.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapParser.java @@ -19,6 +19,7 @@ package itdelatrisu.opsu.beatmap; import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.db.BeatmapDB; import itdelatrisu.opsu.io.MD5InputStreamWrapper; @@ -83,6 +84,10 @@ public class BeatmapParser { // create a new BeatmapSetList BeatmapSetList.create(); + // create a new watch service + if (Options.isWatchServiceEnabled()) + BeatmapWatchService.create(); + // parse all directories parseDirectories(root.listFiles()); } @@ -110,6 +115,9 @@ public class BeatmapParser { List cachedBeatmaps = new LinkedList(); // loaded from database List parsedBeatmaps = new LinkedList(); // loaded from parser + // watch service + BeatmapWatchService ws = (Options.isWatchServiceEnabled()) ? BeatmapWatchService.get() : null; + // parse directories BeatmapSetNode lastNode = null; for (File dir : dirs) { @@ -162,6 +170,8 @@ public class BeatmapParser { if (!beatmaps.isEmpty()) { beatmaps.trimToSize(); allBeatmaps.add(beatmaps); + if (ws != null) + ws.registerAll(dir.toPath()); } // stop parsing files (interrupted) diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapWatchService.java b/src/itdelatrisu/opsu/beatmap/BeatmapWatchService.java new file mode 100644 index 00000000..a287e198 --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/BeatmapWatchService.java @@ -0,0 +1,275 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Options; + +import java.io.IOException; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.newdawn.slick.util.Log; + +/* + * Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of Oracle nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Watches the beatmap directory tree for changes. + * + * @author The Java Tutorials (http://docs.oracle.com/javase/tutorial/essential/io/examples/WatchDir.java) (base) + */ +public class BeatmapWatchService { + /** Beatmap watcher service instance. */ + private static BeatmapWatchService ws; + + /** + * Creates a new watch service instance (overwriting any previous instance), + * registers the beatmap directory, and starts processing events. + */ + public static void create() { + // close the existing watch service + destroy(); + + // create a new watch service + try { + ws = new BeatmapWatchService(); + ws.register(Options.getBeatmapDir().toPath()); + } catch (IOException e) { + ErrorHandler.error("An I/O exception occurred while creating the watch service.", e, true); + return; + } + + // start processing events + ws.start(); + } + + /** + * Destroys the watch service instance, if any. + * Subsequent calls to {@link #get()} will return {@code null}. + */ + public static void destroy() { + if (ws == null) + return; + + try { + ws.watcher.close(); + ws.service.shutdownNow(); + ws = null; + } catch (IOException e) { + ws = null; + ErrorHandler.error("An I/O exception occurred while closing the previous watch service.", e, true); + } + } + + /** + * Returns the single instance of this class. + */ + public static BeatmapWatchService get() { return ws; } + + /** Watch service listener interface. */ + public interface BeatmapWatchServiceListener { + /** Indication that an event was received. */ + public void eventReceived(WatchEvent.Kind kind, Path child); + } + + /** The list of listeners. */ + private static final List listeners = new ArrayList(); + + /** + * Adds a listener. + * @param listener the listener to add + */ + public static void addListener(BeatmapWatchServiceListener listener) { listeners.add(listener); } + + /** + * Removes a listener. + * @param listener the listener to remove + */ + public static void removeListener(BeatmapWatchServiceListener listener) { listeners.remove(listener); } + + /** + * Removes all listeners. + */ + public static void removeListeners() { listeners.clear(); } + + /** The watch service. */ + private final WatchService watcher; + + /** The WatchKey -> Path mapping for registered directories. */ + private final Map keys; + + /** The Executor. */ + private ExecutorService service; + + /** + * Creates the WatchService. + * @throws IOException if an I/O error occurs + */ + private BeatmapWatchService() throws IOException { + this.watcher = FileSystems.getDefault().newWatchService(); + this.keys = new ConcurrentHashMap(); + } + + /** + * Register the given directory with the WatchService. + * @param dir the directory to register + * @throws IOException if an I/O error occurs + */ + private void register(Path dir) throws IOException { + WatchKey key = dir.register(watcher, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY); + keys.put(key, dir); + } + + /** + * Register the given directory, and all its sub-directories, with the WatchService. + * @param start the root directory to register + */ + public void registerAll(final Path start) { + try { + Files.walkFileTree(start, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + try { + register(dir); + } catch (IOException e) { + Log.warn(String.format("Failed to register path '%s' with the watch service.", dir.toString()), e); + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + Log.warn(String.format("Failed to register paths from root directory '%s' with the watch service.", start.toString()), e); + } + } + + @SuppressWarnings("unchecked") + private static WatchEvent cast(WatchEvent event) { + return (WatchEvent) event; + } + + /** + * Start processing events in a new thread. + */ + private void start() { + if (service != null) + return; + + this.service = Executors.newCachedThreadPool(); + service.submit(new Runnable() { + @Override + public void run() { ws.processEvents(); } + }); + } + + /** + * Process all events for keys queued to the watcher + */ + private void processEvents() { + while (true) { + // wait for key to be signaled + WatchKey key; + try { + key = watcher.take(); + } catch (InterruptedException | ClosedWatchServiceException e) { + return; + } + + Path dir = keys.get(key); + if (dir == null) + continue; + + for (WatchEvent event : key.pollEvents()) { + WatchEvent.Kind kind = event.kind(); + if (kind == StandardWatchEventKinds.OVERFLOW) + continue; + + // context for directory entry event is the file name of entry + WatchEvent ev = cast(event); + Path name = ev.context(); + Path child = dir.resolve(name); + //System.out.printf("%s: %s\n", kind.name(), child); + + // fire listeners + for (BeatmapWatchServiceListener listener : listeners) + listener.eventReceived(kind, child); + + // if directory is created, then register it and its sub-directories + if (kind == StandardWatchEventKinds.ENTRY_CREATE) { + if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) + registerAll(child); + } + } + + // reset key and remove from set if directory no longer accessible + if (!key.reset()) { + keys.remove(key); + if (keys.isEmpty()) + break; // all directories are inaccessible + } + } + } +} diff --git a/src/itdelatrisu/opsu/states/OptionsMenu.java b/src/itdelatrisu/opsu/states/OptionsMenu.java index ea9bd251..3792d8b4 100644 --- a/src/itdelatrisu/opsu/states/OptionsMenu.java +++ b/src/itdelatrisu/opsu/states/OptionsMenu.java @@ -97,7 +97,8 @@ public class OptionsMenu extends BasicGameState { GameOption.FIXED_OD, GameOption.CHECKPOINT, GameOption.REPLAY_SEEKING, - GameOption.DISABLE_UPDATER + GameOption.DISABLE_UPDATER, + GameOption.ENABLE_WATCH_SERVICE }); /** Total number of tabs. */ diff --git a/src/itdelatrisu/opsu/states/SongMenu.java b/src/itdelatrisu/opsu/states/SongMenu.java index 63b5f4ea..2fd00018 100644 --- a/src/itdelatrisu/opsu/states/SongMenu.java +++ b/src/itdelatrisu/opsu/states/SongMenu.java @@ -36,6 +36,8 @@ import itdelatrisu.opsu.beatmap.BeatmapParser; import itdelatrisu.opsu.beatmap.BeatmapSetList; import itdelatrisu.opsu.beatmap.BeatmapSetNode; import itdelatrisu.opsu.beatmap.BeatmapSortOrder; +import itdelatrisu.opsu.beatmap.BeatmapWatchService; +import itdelatrisu.opsu.beatmap.BeatmapWatchService.BeatmapWatchServiceListener; import itdelatrisu.opsu.db.BeatmapDB; import itdelatrisu.opsu.db.ScoreDB; import itdelatrisu.opsu.states.ButtonMenu.MenuState; @@ -47,6 +49,9 @@ import itdelatrisu.opsu.ui.animations.AnimatedValue; import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.io.File; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent.Kind; import java.util.Map; import java.util.Stack; @@ -205,6 +210,9 @@ public class SongMenu extends BasicGameState { /** The text length of the last string in the search TextField. */ private int lastSearchTextLength = -1; + /** Whether the song folder changed (notified via the watch service). */ + private boolean songFolderChanged = false; + // game-related variables private GameContainer container; private StateBasedGame game; @@ -283,6 +291,19 @@ public class SongMenu extends BasicGameState { int loaderDim = GameImage.MENU_MUSICNOTE.getImage().getWidth(); SpriteSheet spr = new SpriteSheet(GameImage.MENU_LOADER.getImage(), loaderDim, loaderDim); loader = new Animation(spr, 50); + + // beatmap watch service listener + final StateBasedGame game_ = game; + BeatmapWatchService.addListener(new BeatmapWatchServiceListener() { + @Override + public void eventReceived(Kind kind, Path child) { + if (!songFolderChanged && kind != StandardWatchEventKinds.ENTRY_MODIFY) { + songFolderChanged = true; + if (game_.getCurrentStateID() == Opsu.STATE_SONGMENU) + UI.sendBarNotification("Changes in Songs folder detected. Hit F5 to refresh."); + } + } + }); } @Override @@ -775,8 +796,12 @@ public class SongMenu extends BasicGameState { break; case Input.KEY_F5: SoundController.playSound(SoundEffect.MENUHIT); - ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(MenuState.RELOAD); - game.enterState(Opsu.STATE_BUTTONMENU); + if (songFolderChanged) + reloadBeatmaps(false); + else { + ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(MenuState.RELOAD); + game.enterState(Opsu.STATE_BUTTONMENU); + } break; case Input.KEY_DELETE: if (focusNode == null) @@ -949,8 +974,12 @@ public class SongMenu extends BasicGameState { // reset song stack randomStack = new Stack(); + // reload beatmaps if song folder changed + if (songFolderChanged && stateAction != MenuState.RELOAD) + reloadBeatmaps(false); + // set focus node if not set (e.g. theme song playing) - if (focusNode == null && BeatmapSetList.get().size() > 0) + else if (focusNode == null && BeatmapSetList.get().size() > 0) setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); // reset music track @@ -1069,44 +1098,7 @@ public class SongMenu extends BasicGameState { } break; case RELOAD: // reload beatmaps - // reset state and node references - MusicController.reset(); - startNode = focusNode = null; - scoreMap = null; - focusScores = null; - oldFocusNode = null; - randomStack = new Stack(); - songInfo = null; - hoverOffset.setTime(0); - hoverIndex = -1; - search.setText(""); - searchTimer = SEARCH_DELAY; - searchTransitionTimer = SEARCH_TRANSITION_TIME; - searchResultString = null; - - // reload songs in new thread - reloadThread = new Thread() { - @Override - public void run() { - // clear the beatmap cache - BeatmapDB.clearDatabase(); - - // invoke unpacker and parser - File beatmapDir = Options.getBeatmapDir(); - OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); - BeatmapParser.parseAllFiles(beatmapDir); - - // initialize song list - if (BeatmapSetList.get().size() > 0) { - BeatmapSetList.get().init(); - setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); - } else - MusicController.playThemeSong(); - - reloadThread = null; - } - }; - reloadThread.start(); + reloadBeatmaps(true); break; default: break; @@ -1310,6 +1302,58 @@ public class SongMenu extends BasicGameState { return null; // incorrect map } + /** + * Reloads all beatmaps. + * @param fullReload if true, also clear the beatmap cache and invoke the unpacker + */ + private void reloadBeatmaps(final boolean fullReload) { + songFolderChanged = false; + + // reset state and node references + MusicController.reset(); + startNode = focusNode = null; + scoreMap = null; + focusScores = null; + oldFocusNode = null; + randomStack = new Stack(); + songInfo = null; + hoverOffset.setTime(0); + hoverIndex = -1; + search.setText(""); + searchTimer = SEARCH_DELAY; + searchTransitionTimer = SEARCH_TRANSITION_TIME; + searchResultString = null; + + // reload songs in new thread + reloadThread = new Thread() { + @Override + public void run() { + File beatmapDir = Options.getBeatmapDir(); + + if (fullReload) { + // clear the beatmap cache + BeatmapDB.clearDatabase(); + + // invoke unpacker + OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); + } + + // invoke parser + BeatmapParser.parseAllFiles(beatmapDir); + + // initialize song list + if (BeatmapSetList.get().size() > 0) { + BeatmapSetList.get().init(); + setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); + } else + MusicController.playThemeSong(); + + reloadThread = null; + } + }; + reloadThread.start(); + } + /** * Starts the game. */ diff --git a/src/itdelatrisu/opsu/states/Splash.java b/src/itdelatrisu/opsu/states/Splash.java index c97ab6b3..b5bed1ee 100644 --- a/src/itdelatrisu/opsu/states/Splash.java +++ b/src/itdelatrisu/opsu/states/Splash.java @@ -27,6 +27,7 @@ import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.beatmap.BeatmapParser; import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapWatchService; import itdelatrisu.opsu.replay.ReplayImporter; import itdelatrisu.opsu.ui.UI; import itdelatrisu.opsu.ui.animations.AnimatedValue; @@ -63,6 +64,9 @@ public class Splash extends BasicGameState { /** Whether the skin being loaded is a new skin (for program restarts). */ private boolean newSkin = false; + /** Whether the watch service is newly enabled (for program restarts). */ + private boolean watchServiceChange = false; + /** Logo alpha level. */ private AnimatedValue logoAlpha; @@ -84,6 +88,9 @@ public class Splash extends BasicGameState { if (Options.getSkin() != null) this.newSkin = (Options.getSkin().getDirectory() != Options.getSkinDir()); + // check if watch service newly enabled + this.watchServiceChange = Options.isWatchServiceEnabled() && BeatmapWatchService.get() == null; + // load Utils class first (needed in other 'init' methods) Utils.init(container, game); @@ -108,13 +115,18 @@ public class Splash extends BasicGameState { // resources already loaded (from application restart) if (BeatmapSetList.get() != null) { - // reload sounds if skin changed - if (newSkin) { + if (newSkin || watchServiceChange) { // need to reload resources thread = new Thread() { @Override public void run() { + // reload beatmaps if watch service newly enabled + if (watchServiceChange) + BeatmapParser.parseAllFiles(Options.getBeatmapDir()); + + // reload sounds if skin changed // TODO: only reload each sound if actually needed? - SoundController.init(); + if (newSkin) + SoundController.init(); finished = true; thread = null; diff --git a/src/itdelatrisu/opsu/ui/UI.java b/src/itdelatrisu/opsu/ui/UI.java index 64471614..b855aa8b 100644 --- a/src/itdelatrisu/opsu/ui/UI.java +++ b/src/itdelatrisu/opsu/ui/UI.java @@ -64,7 +64,7 @@ public class UI { private static int barNotifTimer = -1; /** Duration, in milliseconds, to display bar notifications. */ - private static final int BAR_NOTIFICATION_TIME = 1250; + private static final int BAR_NOTIFICATION_TIME = 1500; /** The current tooltip. */ private static String tooltip;