Load beatmap background images in a new thread.
This eliminates the game-wide lag (100-200ms on my computer) when switching song nodes. Attempted to mask the loading time with a fade-in effect. Signed-off-by: Jeffrey Han <itdelatrisu@gmail.com>
This commit is contained in:
parent
e767800702
commit
3214916d60
|
@ -27,7 +27,6 @@ import java.util.Map;
|
||||||
|
|
||||||
import org.newdawn.slick.Color;
|
import org.newdawn.slick.Color;
|
||||||
import org.newdawn.slick.Image;
|
import org.newdawn.slick.Image;
|
||||||
import org.newdawn.slick.SlickException;
|
|
||||||
import org.newdawn.slick.util.Log;
|
import org.newdawn.slick.util.Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -39,20 +38,19 @@ public class Beatmap implements Comparable<Beatmap> {
|
||||||
|
|
||||||
/** Background image cache. */
|
/** Background image cache. */
|
||||||
@SuppressWarnings("serial")
|
@SuppressWarnings("serial")
|
||||||
private static final LRUCache<File, Image> bgImageCache = new LRUCache<File, Image>(10) {
|
private static final LRUCache<File, ImageLoader> bgImageCache = new LRUCache<File, ImageLoader>(10) {
|
||||||
@Override
|
@Override
|
||||||
public void eldestRemoved(Map.Entry<File, Image> eldest) {
|
public void eldestRemoved(Map.Entry<File, ImageLoader> eldest) {
|
||||||
Image img = eldest.getValue();
|
if (eldest.getKey() == lastBG)
|
||||||
if (img != null && !img.isDestroyed()) {
|
lastBG = null;
|
||||||
try {
|
ImageLoader imageLoader = eldest.getValue();
|
||||||
img.destroy(); // destroy the removed image
|
imageLoader.destroy();
|
||||||
} catch (SlickException e) {
|
|
||||||
Log.warn(String.format("Failed to destroy image '%s'.", img.getResourceReference()), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The last background image loaded. */
|
||||||
|
private static File lastBG;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the background image cache.
|
* Clears the background image cache.
|
||||||
* <p>
|
* <p>
|
||||||
|
@ -276,49 +274,74 @@ public class Beatmap implements Comparable<Beatmap> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws the beatmap background.
|
* Loads the beatmap background image.
|
||||||
|
*/
|
||||||
|
public void loadBackground() {
|
||||||
|
if (bg == null || bgImageCache.containsKey(bg) || !bg.isFile())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (lastBG != null) {
|
||||||
|
ImageLoader lastImageLoader = bgImageCache.get(lastBG);
|
||||||
|
if (lastImageLoader != null && lastImageLoader.isLoading()) {
|
||||||
|
lastImageLoader.interrupt(); // only allow loading one image at a time
|
||||||
|
bgImageCache.remove(lastBG);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImageLoader imageLoader = new ImageLoader(bg);
|
||||||
|
bgImageCache.put(bg, imageLoader);
|
||||||
|
imageLoader.load(true);
|
||||||
|
lastBG = bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the beatmap background image is currently loading.
|
||||||
|
* @return true if loading
|
||||||
|
*/
|
||||||
|
public boolean isBackgroundLoading() {
|
||||||
|
if (bg == null)
|
||||||
|
return false;
|
||||||
|
ImageLoader imageLoader = bgImageCache.get(bg);
|
||||||
|
return (imageLoader != null && imageLoader.isLoading());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the beatmap background image.
|
||||||
* @param width the container width
|
* @param width the container width
|
||||||
* @param height the container height
|
* @param height the container height
|
||||||
* @param alpha the alpha value
|
* @param alpha the alpha value
|
||||||
* @param stretch if true, stretch to screen dimensions; otherwise, maintain aspect ratio
|
* @param stretch if true, stretch to screen dimensions; otherwise, maintain aspect ratio
|
||||||
* @return true if successful, false if any errors were produced
|
* @return true if successful, false if any errors were produced
|
||||||
*/
|
*/
|
||||||
public boolean drawBG(int width, int height, float alpha, boolean stretch) {
|
public boolean drawBackground(int width, int height, float alpha, boolean stretch) {
|
||||||
if (bg == null)
|
if (bg == null)
|
||||||
return false;
|
return false;
|
||||||
try {
|
|
||||||
Image bgImage = bgImageCache.get(this);
|
|
||||||
if (bgImage == null) {
|
|
||||||
if (!bg.isFile())
|
|
||||||
return false;
|
|
||||||
bgImage = new Image(bg.getAbsolutePath());
|
|
||||||
bgImageCache.put(bg, bgImage);
|
|
||||||
}
|
|
||||||
|
|
||||||
int swidth = width;
|
ImageLoader imageLoader = bgImageCache.get(bg);
|
||||||
int sheight = height;
|
if (imageLoader == null)
|
||||||
if (!stretch) {
|
|
||||||
// fit image to screen
|
|
||||||
if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height) // x > y
|
|
||||||
sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth());
|
|
||||||
else
|
|
||||||
swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight());
|
|
||||||
} else {
|
|
||||||
// fill screen while maintaining aspect ratio
|
|
||||||
if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height) // x > y
|
|
||||||
swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight());
|
|
||||||
else
|
|
||||||
sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth());
|
|
||||||
}
|
|
||||||
bgImage = bgImage.getScaledCopy(swidth, sheight);
|
|
||||||
|
|
||||||
bgImage.setAlpha(alpha);
|
|
||||||
bgImage.drawCentered(width / 2, height / 2);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.warn(String.format("Failed to get background image '%s'.", bg), e);
|
|
||||||
bg = null; // don't try to load the file again until a restart
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
Image bgImage = imageLoader.getImage();
|
||||||
|
if (bgImage == null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
int swidth = width;
|
||||||
|
int sheight = height;
|
||||||
|
if (!stretch) {
|
||||||
|
// fit image to screen
|
||||||
|
if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height) // x > y
|
||||||
|
sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth());
|
||||||
|
else
|
||||||
|
swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight());
|
||||||
|
} else {
|
||||||
|
// fill screen while maintaining aspect ratio
|
||||||
|
if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height) // x > y
|
||||||
|
swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight());
|
||||||
|
else
|
||||||
|
sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth());
|
||||||
}
|
}
|
||||||
|
bgImage = bgImage.getScaledCopy(swidth, sheight);
|
||||||
|
bgImage.setAlpha(alpha);
|
||||||
|
bgImage.drawCentered(width / 2, height / 2);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
179
src/itdelatrisu/opsu/beatmap/ImageLoader.java
Normal file
179
src/itdelatrisu/opsu/beatmap/ImageLoader.java
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
/*
|
||||||
|
* 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.beatmap;
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
|
import org.newdawn.slick.Image;
|
||||||
|
import org.newdawn.slick.SlickException;
|
||||||
|
import org.newdawn.slick.opengl.ImageData;
|
||||||
|
import org.newdawn.slick.opengl.ImageDataFactory;
|
||||||
|
import org.newdawn.slick.opengl.LoadableImageData;
|
||||||
|
import org.newdawn.slick.util.Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple threaded image loader for a single image file.
|
||||||
|
*/
|
||||||
|
public class ImageLoader {
|
||||||
|
/** The image file. */
|
||||||
|
private final File file;
|
||||||
|
|
||||||
|
/** The loaded image. */
|
||||||
|
private Image image;
|
||||||
|
|
||||||
|
/** The image data. */
|
||||||
|
private LoadedImageData data;
|
||||||
|
|
||||||
|
/** The image loader thread. */
|
||||||
|
private Thread loaderThread;
|
||||||
|
|
||||||
|
/** ImageData wrapper, needed because {@code ImageIOImageData} doesn't implement {@code getImageBufferData()}. */
|
||||||
|
private class LoadedImageData implements ImageData {
|
||||||
|
/** The image data implementation. */
|
||||||
|
private final ImageData imageData;
|
||||||
|
|
||||||
|
/** The stored image. */
|
||||||
|
private final ByteBuffer buffer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
* @param imageData the class holding the image properties
|
||||||
|
* @param buffer the stored image
|
||||||
|
*/
|
||||||
|
public LoadedImageData(ImageData imageData, ByteBuffer buffer) {
|
||||||
|
this.imageData = imageData;
|
||||||
|
this.buffer = buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public int getDepth() { return imageData.getDepth(); }
|
||||||
|
@Override public int getWidth() { return imageData.getWidth(); }
|
||||||
|
@Override public int getHeight() { return imageData.getHeight();}
|
||||||
|
@Override public int getTexWidth() { return imageData.getTexWidth(); }
|
||||||
|
@Override public int getTexHeight() { return imageData.getTexHeight(); }
|
||||||
|
@Override public ByteBuffer getImageBufferData() { return buffer; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Image loading thread. */
|
||||||
|
private class ImageLoaderThread extends Thread {
|
||||||
|
/** The image file input stream. */
|
||||||
|
private BufferedInputStream in;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void interrupt() {
|
||||||
|
super.interrupt();
|
||||||
|
if (in != null) {
|
||||||
|
try {
|
||||||
|
in.close(); // interrupt I/O
|
||||||
|
} catch (IOException e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// load image data into a ByteBuffer to use constructor Image(ImageData)
|
||||||
|
LoadableImageData imageData = ImageDataFactory.getImageDataFor(file.getAbsolutePath());
|
||||||
|
try (BufferedInputStream in = this.in = new BufferedInputStream(new FileInputStream(file))) {
|
||||||
|
ByteBuffer textureBuffer = imageData.loadImage(in, false, null);
|
||||||
|
if (!isInterrupted())
|
||||||
|
data = new LoadedImageData(imageData, textureBuffer);
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (!isInterrupted())
|
||||||
|
Log.warn(String.format("Failed to load background image '%s'.", file), e);
|
||||||
|
}
|
||||||
|
this.in = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor. Call {@link ImageLoader#load(boolean)} to load the image.
|
||||||
|
* @param file the image file
|
||||||
|
*/
|
||||||
|
public ImageLoader(File file) {
|
||||||
|
this.file = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the image.
|
||||||
|
* @param threaded true to load the image data in a new thread
|
||||||
|
*/
|
||||||
|
public void load(boolean threaded) {
|
||||||
|
if (!file.isFile())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (threaded) {
|
||||||
|
if (loaderThread != null && loaderThread.isAlive())
|
||||||
|
loaderThread.interrupt();
|
||||||
|
loaderThread = new ImageLoaderThread();
|
||||||
|
loaderThread.start();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
image = new Image(file.getAbsolutePath());
|
||||||
|
} catch (SlickException e) {
|
||||||
|
Log.warn(String.format("Failed to load background image '%s'.", file), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the image.
|
||||||
|
* @return the loaded image, or null if not loaded
|
||||||
|
*/
|
||||||
|
public Image getImage() {
|
||||||
|
if (image == null && data != null) {
|
||||||
|
image = new Image(data);
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether an image is currently loading in another thread.
|
||||||
|
* @return true if loading, false otherwise
|
||||||
|
*/
|
||||||
|
public boolean isLoading() { return (loaderThread != null && loaderThread.isAlive()); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interrupts the image loader, if running.
|
||||||
|
*/
|
||||||
|
public void interrupt() {
|
||||||
|
if (isLoading())
|
||||||
|
loaderThread.interrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases all resources.
|
||||||
|
*/
|
||||||
|
public void destroy() {
|
||||||
|
interrupt();
|
||||||
|
loaderThread = null;
|
||||||
|
if (image != null && !image.isDestroyed()) {
|
||||||
|
try {
|
||||||
|
image.destroy();
|
||||||
|
} catch (SlickException e) {
|
||||||
|
Log.warn(String.format("Failed to destroy image '%s'.", image.getResourceReference()), e);
|
||||||
|
}
|
||||||
|
image = null;
|
||||||
|
}
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -305,7 +305,7 @@ public class Game extends BasicGameState {
|
||||||
else
|
else
|
||||||
dimLevel = 1f;
|
dimLevel = 1f;
|
||||||
}
|
}
|
||||||
if (Options.isDefaultPlayfieldForced() || !beatmap.drawBG(width, height, dimLevel, false)) {
|
if (Options.isDefaultPlayfieldForced() || !beatmap.drawBackground(width, height, dimLevel, false)) {
|
||||||
Image playfield = GameImage.PLAYFIELD.getImage();
|
Image playfield = GameImage.PLAYFIELD.getImage();
|
||||||
playfield.setAlpha(dimLevel);
|
playfield.setAlpha(dimLevel);
|
||||||
playfield.draw();
|
playfield.draw();
|
||||||
|
|
|
@ -106,7 +106,7 @@ public class GameRanking extends BasicGameState {
|
||||||
Beatmap beatmap = MusicController.getBeatmap();
|
Beatmap beatmap = MusicController.getBeatmap();
|
||||||
|
|
||||||
// background
|
// background
|
||||||
if (!beatmap.drawBG(width, height, 0.7f, true))
|
if (!beatmap.drawBackground(width, height, 0.7f, true))
|
||||||
GameImage.PLAYFIELD.getImage().draw(0,0);
|
GameImage.PLAYFIELD.getImage().draw(0,0);
|
||||||
|
|
||||||
// ranking screen elements
|
// ranking screen elements
|
||||||
|
|
|
@ -234,7 +234,7 @@ public class MainMenu extends BasicGameState {
|
||||||
// draw background
|
// draw background
|
||||||
Beatmap beatmap = MusicController.getBeatmap();
|
Beatmap beatmap = MusicController.getBeatmap();
|
||||||
if (Options.isDynamicBackgroundEnabled() &&
|
if (Options.isDynamicBackgroundEnabled() &&
|
||||||
beatmap != null && beatmap.drawBG(width, height, bgAlpha.getValue(), true))
|
beatmap != null && beatmap.drawBackground(width, height, bgAlpha.getValue(), true))
|
||||||
;
|
;
|
||||||
else {
|
else {
|
||||||
Image bg = GameImage.MENU_BG.getImage();
|
Image bg = GameImage.MENU_BG.getImage();
|
||||||
|
@ -349,7 +349,9 @@ public class MainMenu extends BasicGameState {
|
||||||
MusicController.toggleTrackDimmed(0.33f);
|
MusicController.toggleTrackDimmed(0.33f);
|
||||||
|
|
||||||
// fade in background
|
// fade in background
|
||||||
bgAlpha.update(delta);
|
Beatmap beatmap = MusicController.getBeatmap();
|
||||||
|
if (!(Options.isDynamicBackgroundEnabled() && beatmap != null && beatmap.isBackgroundLoading()))
|
||||||
|
bgAlpha.update(delta);
|
||||||
|
|
||||||
// buttons
|
// buttons
|
||||||
int centerX = container.getWidth() / 2;
|
int centerX = container.getWidth() / 2;
|
||||||
|
|
|
@ -216,6 +216,12 @@ public class SongMenu extends BasicGameState {
|
||||||
/** Whether the song folder changed (notified via the watch service). */
|
/** Whether the song folder changed (notified via the watch service). */
|
||||||
private boolean songFolderChanged = false;
|
private boolean songFolderChanged = false;
|
||||||
|
|
||||||
|
/** The last background image. */
|
||||||
|
private File lastBackgroundImage;
|
||||||
|
|
||||||
|
/** Background alpha level (for fade-in effect). */
|
||||||
|
private AnimatedValue bgAlpha = new AnimatedValue(800, 0f, 1f, AnimationEquation.OUT_QUAD);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Beatmaps whose difficulties were recently computed (if flag is non-null).
|
* Beatmaps whose difficulties were recently computed (if flag is non-null).
|
||||||
* Unless the Boolean flag is null, then upon removal, the beatmap's objects will
|
* Unless the Boolean flag is null, then upon removal, the beatmap's objects will
|
||||||
|
@ -344,7 +350,7 @@ public class SongMenu extends BasicGameState {
|
||||||
// background
|
// background
|
||||||
if (focusNode != null) {
|
if (focusNode != null) {
|
||||||
Beatmap focusNodeBeatmap = focusNode.getBeatmapSet().get(focusNode.beatmapIndex);
|
Beatmap focusNodeBeatmap = focusNode.getBeatmapSet().get(focusNode.beatmapIndex);
|
||||||
if (!focusNodeBeatmap.drawBG(width, height, 1.0f, true))
|
if (!focusNodeBeatmap.drawBackground(width, height, bgAlpha.getValue(), true))
|
||||||
GameImage.PLAYFIELD.getImage().draw();
|
GameImage.PLAYFIELD.getImage().draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -545,6 +551,13 @@ public class SongMenu extends BasicGameState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fade in background
|
||||||
|
if (focusNode != null) {
|
||||||
|
Beatmap focusNodeBeatmap = focusNode.getBeatmapSet().get(focusNode.beatmapIndex);
|
||||||
|
if (!focusNodeBeatmap.isBackgroundLoading())
|
||||||
|
bgAlpha.update(delta);
|
||||||
|
}
|
||||||
|
|
||||||
// search
|
// search
|
||||||
search.setFocus(true);
|
search.setFocus(true);
|
||||||
searchTimer += delta;
|
searchTimer += delta;
|
||||||
|
@ -996,6 +1009,7 @@ public class SongMenu extends BasicGameState {
|
||||||
beatmapMenuTimer = -1;
|
beatmapMenuTimer = -1;
|
||||||
searchTransitionTimer = SEARCH_TRANSITION_TIME;
|
searchTransitionTimer = SEARCH_TRANSITION_TIME;
|
||||||
songInfo = null;
|
songInfo = null;
|
||||||
|
bgAlpha.setTime(bgAlpha.getDuration());
|
||||||
|
|
||||||
// reset song stack
|
// reset song stack
|
||||||
randomStack = new Stack<SongNode>();
|
randomStack = new Stack<SongNode>();
|
||||||
|
@ -1253,6 +1267,14 @@ public class SongMenu extends BasicGameState {
|
||||||
if (startNode.index == focusNode.index && startNode.beatmapIndex == -1)
|
if (startNode.index == focusNode.index && startNode.beatmapIndex == -1)
|
||||||
changeIndex(1);
|
changeIndex(1);
|
||||||
|
|
||||||
|
// load background image
|
||||||
|
beatmap.loadBackground();
|
||||||
|
boolean isBgNull = lastBackgroundImage == null || beatmap.bg == null;
|
||||||
|
if ((isBgNull && lastBackgroundImage != beatmap.bg) || (!isBgNull && !beatmap.bg.equals(lastBackgroundImage))) {
|
||||||
|
bgAlpha.setTime(0);
|
||||||
|
lastBackgroundImage = beatmap.bg;
|
||||||
|
}
|
||||||
|
|
||||||
return oldFocus;
|
return oldFocus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1352,6 +1374,7 @@ public class SongMenu extends BasicGameState {
|
||||||
searchTimer = SEARCH_DELAY;
|
searchTimer = SEARCH_DELAY;
|
||||||
searchTransitionTimer = SEARCH_TRANSITION_TIME;
|
searchTransitionTimer = SEARCH_TRANSITION_TIME;
|
||||||
searchResultString = null;
|
searchResultString = null;
|
||||||
|
lastBackgroundImage = null;
|
||||||
|
|
||||||
// reload songs in new thread
|
// reload songs in new thread
|
||||||
reloadThread = new Thread() {
|
reloadThread = new Thread() {
|
||||||
|
|
|
@ -77,6 +77,11 @@ public class AnimatedValue {
|
||||||
updateValue();
|
updateValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total animation duration, in milliseconds.
|
||||||
|
*/
|
||||||
|
public int getDuration() { return duration; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the animation duration.
|
* Sets the animation duration.
|
||||||
* @param duration the new duration, in milliseconds
|
* @param duration the new duration, in milliseconds
|
||||||
|
|
Loading…
Reference in New Issue
Block a user