2

So I'm building this music player app which plays notes which are dragged and dropped onto a JLabel. When I hit the play button, I want each note to be highlighted with a delay value corresponding to that note. I used a Swing Timer for this but the problem is, it just loops with a constant delay which is specified in the constructor.

playButton.addActionListener(e -> {
        timerI = 0;
        System.out.println("Entered onAction");

        Timer t = new Timer(1000, e1 -> {
            if (timerI < 24) {
                NoteLabel thisNote = (NoteLabel)staff.getComponent(timerI);
                NoteIcon thisIcon = thisNote.getIcon();
                String noteName = thisIcon.getNoteName();
                thisNote.setIcon(noteMap.get(noteName + "S"));
                timerI++;
            }
        });
        t.start();
    });

It works and all, but I want to make the timer delay dynamic. Each NoteIcon object has an attribute which holds a delay value and I want the timer to wait a different amount of time depending on which NoteIcon is fetched in that loop. (For exait 1 sec for the first loop, then 2, 4, 1 etc) How do I do this?

Pawan Bhandarkar
  • 304
  • 2
  • 10
  • 2
    I would possibly look at it in a different way, instead, I would have a single `Timer` which represented the "play" speed. I would then use a "time line", where each not is placed on it at specific points in time (or if you normalise it, at a specific point between 0-1 and then the play speed becomes dynamic). On each tick of the `Timer`, you could determine if any note needs to be played, play and highlight the note based on it's own requirements - the highlight then becomes a "duration based" animation, allowing you to use the same `Timer` and it's ticks to change the state of the note – MadProgrammer Oct 23 '18 at 20:03
  • Quick and dirty -- you can get a reference to the Timer via your ActionEvent, e1's, `getSource()` method. So `Timer myTimer = (Timer) e1.getSource();` returns the Timer. The Timer class has two methods, `getDelay()` and a `setDelay(int delay)` that could work for you. Otherwise, do what @MadProgrammer suggests for a cleaner solution – Hovercraft Full Of Eels Oct 23 '18 at 20:06
  • @HovercraftFullOfEels Yes, that worked! Thanks a lot. – Pawan Bhandarkar Oct 23 '18 at 20:26
  • @MadProgrammer Your suggestion sounds clean but I'm kinda new to Swings in general and I'm not sure how to implement the "time line" that you mentioned. – Pawan Bhandarkar Oct 23 '18 at 20:27
  • Yes, if this is to recreate music, I would definitely go with @MadProgrammer's answer – Hovercraft Full Of Eels Oct 23 '18 at 20:28

1 Answers1

4

Caveats:

  • Animation is NOT simple. It's complicated. It has a number of important theories around it which are designed to make animation look good
  • Good animation is hard
  • Animation is the illusion of change over time
  • Much of what I'm presenting is based on library code, so it will be slightly convoluted, but is designed for re-use and abstraction

Theory tl;dr

Okay, some really boring theory. But first, things I'm not going to talk about - easement or animation curves. These change the speed at which animation is played over a given period of time, making the animation look more natural, but I could spend the entire answer talking about nothing else :/

The first thing you want to do is abstract your concepts. For example. Animation is typically a change over time (some animation is linear over an infinite amount of time, but, let's try and keep it within the confines of the question).

So immediately, we have two important concepts. The first is duration, the second is the normalised progress from point A to point B over that duration. That is, at half the duration, the progression will be 0.5. This is important, as it allows us to abstract the concepts and make the framework dynamic.

Animation too fast? Change the duration and everything else remains unchanged.

A timeline...

Okay, music is a timeline. It has a defined start and end point (again, keep it simple) and events along that timeline which "do stuff", independent of the music timeline (ie, each note can play for a specified duration, independent of the music timeline, which will have moved on or even finished)

First, we need a note...

public class Note {
    private Duration duration;

    public Note(Duration duration) {
        this.duration = duration;
    }

    public Duration getDuration() {
        return duration;
    }
}

And a "event" based timeline, which describes when those notes should be played over a normalised period of time.

public static class EventTimeLine<T> {

    private Map<Double, KeyFrame<T>> mapEvents;

    public EventTimeLine() {
        mapEvents = new TreeMap<>();
    }

    public void add(double progress, T value) {
        mapEvents.put(progress, new KeyFrame<T>(progress, value));
    }

    public List<T> getValues() {
        return Collections.unmodifiableList(mapEvents.values().stream()
                .map(kf -> kf.getValue())
                .collect(Collectors.toList()));
    }

    public double getPointOnTimeLineFor(T value) {
        for (Map.Entry<Double, KeyFrame<T>> entry : mapEvents.entrySet()) {
            if (entry.getValue().getValue() == value) {
                return entry.getKey();
            }
        }

        return -1;
    }

    public List<T> getValuesAt(double progress) {

        if (progress < 0) {
            progress = 0;
        } else if (progress > 1) {
            progress = 1;
        }

        return getKeyFramesBetween(progress, 0.01f)
                .stream()
                .map(kf -> kf.getValue())
                .collect(Collectors.toList());
    }

    public List<KeyFrame<T>> getKeyFramesBetween(double progress, double delta) {

        int startAt = 0;

        List<Double> keyFrames = new ArrayList<>(mapEvents.keySet());
        while (startAt < keyFrames.size() && keyFrames.get(startAt) <= progress - delta) {
            startAt++;
        }

        startAt = Math.min(keyFrames.size() - 1, startAt);
        int endAt = startAt;
        while (endAt < keyFrames.size() && keyFrames.get(endAt) <= progress + delta) {
            endAt++;
        }
        endAt = Math.min(keyFrames.size() - 1, endAt);

        List<KeyFrame<T>> frames = new ArrayList<>(5);
        for (int index = startAt; index <= endAt; index++) {
            KeyFrame<T> keyFrame = mapEvents.get(keyFrames.get(index));
            if (keyFrame.getProgress() >= progress - delta
                    && keyFrame.getProgress() <= progress + delta) {
                frames.add(keyFrame);
            }
        }

        return frames;

    }

    public class KeyFrame<T> {

        private double progress;
        private T value;

        public KeyFrame(double progress, T value) {
            this.progress = progress;
            this.value = value;
        }

        public double getProgress() {
            return progress;
        }

        public T getValue() {
            return value;
        }

        @Override
        public String toString() {
            return "KeyFrame progress = " + getProgress() + "; value = " + getValue();
        }

    }

}

Then you could create a music timeline something like...

musicTimeLine = new EventTimeLine<Note>();
musicTimeLine.add(0.1f, new Note(Duration.ofMillis(1000)));
musicTimeLine.add(0.12f, new Note(Duration.ofMillis(500)));
musicTimeLine.add(0.2f, new Note(Duration.ofMillis(500)));
musicTimeLine.add(0.21f, new Note(Duration.ofMillis(500)));
musicTimeLine.add(0.22f, new Note(Duration.ofMillis(500)));
musicTimeLine.add(0.25f, new Note(Duration.ofMillis(1000)));
musicTimeLine.add(0.4f, new Note(Duration.ofMillis(2000)));
musicTimeLine.add(0.5f, new Note(Duration.ofMillis(2000)));
musicTimeLine.add(0.7f, new Note(Duration.ofMillis(2000)));
musicTimeLine.add(0.8f, new Note(Duration.ofMillis(2000)));

Note, here I've defined the notes as running at a fixed duration. You "could" make them play as a percentage of the duration of the timeline ... but just saying that is hard, so I'll leave that up to you ;)

Animation Engine

The presented (simple) animation engine, uses a single Timer, running at high speed, as a central "tick" engine.

It then notifies Animatable objects which actually perform the underlying animation.

Normally, I animated over a range of values (from - to), but in this case, we're actually only interested in the amount of time that the animation has played. From that we can determine what notes should be getting played AND animate the notes, in the case of this example, change the alpha value, but you could equally change the size of the objects representing the notes, but that would be a different Animatable implementation, which I've not presented here.

If you're interested, my SuperSimpleSwingAnimationFramework, which this example is loosely based on, contains "range" based Animatables ... fun stuff.

In the example, an Animatable is used to drive the music EventTimeLine, which simply checks the timeline for any "notes" which need to be played at the specific point in time.

A second BlendingTimeLine is used to control the alpha value (0-1-0). Each note is then provided with it's own Animatable which drives this blending timeline, and uses its values to animate the change in the alpha of the highlighted note.

This is a great example of the decoupled nature of the API - the BlendingTimeLine is used for ALL the notes. The Animatables simply take the amount of time they have played and extract the required value from the timeline and apply it.

This means that each note is only highlighted as long as its own duration specifies, all independently.

Runnable Example...

nb: If I was doing this, I'd have abstracted the solution to a much higher level

Twang my strings

import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Line2D;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.Timer;

public class Test {

    public static void main(String[] args) {
        new Test();
    }

    public Test() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                JFrame frame = new JFrame();
                frame.add(new TestPane());
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    public class TestPane extends JPanel {

        private EventTimeLine<Note> musicTimeLine;
        private DefaultDurationAnimatable timeLineAnimatable;

        private Double playProgress;

        private Set<Note> playing = new HashSet<Note>(5);
        private Map<Note, Double> noteAlpha = new HashMap<>(5);

        private DoubleBlender blender = new DoubleBlender();
        private BlendingTimeLine<Double> alphaTimeLine = new BlendingTimeLine<>(blender);

        public TestPane() {
            musicTimeLine = new EventTimeLine<Note>();
            musicTimeLine.add(0.1f, new Note(Duration.ofMillis(1000)));
            musicTimeLine.add(0.12f, new Note(Duration.ofMillis(500)));
            musicTimeLine.add(0.2f, new Note(Duration.ofMillis(500)));
            musicTimeLine.add(0.21f, new Note(Duration.ofMillis(500)));
            musicTimeLine.add(0.22f, new Note(Duration.ofMillis(500)));
            musicTimeLine.add(0.25f, new Note(Duration.ofMillis(1000)));
            musicTimeLine.add(0.4f, new Note(Duration.ofMillis(2000)));
            musicTimeLine.add(0.5f, new Note(Duration.ofMillis(2000)));
            musicTimeLine.add(0.7f, new Note(Duration.ofMillis(2000)));
            musicTimeLine.add(0.8f, new Note(Duration.ofMillis(2000)));

            alphaTimeLine.add(0.0f, 0.0);
            alphaTimeLine.add(0.5f, 1.0);
            alphaTimeLine.add(1.0f, 0.0);

            timeLineAnimatable = new DefaultDurationAnimatable(Duration.ofSeconds(10),
                    new AnimatableListener() {
                @Override
                public void animationChanged(Animatable animator) {
                    double progress = timeLineAnimatable.getPlayedDuration();
                    playProgress = progress;
                    List<Note> notes = musicTimeLine.getValuesAt(progress);
                    if (notes.size() > 0) {
                        System.out.println(">> " + progress + " @ " + notes.size());
                        for (Note note : notes) {
                            playNote(note);
                        }
                    }
                    repaint();
                }
            }, null);

            timeLineAnimatable.start();
        }

        protected void playNote(Note note) {
            // Note is already playing...
            // Equally, we could maintain a reference to the animator, mapped to
            // the note, but what ever...
            if (playing.contains(note)) {
                return;
            }
            playing.add(note);

            DurationAnimatable noteAnimatable = new DefaultDurationAnimatable(note.getDuration(), new AnimatableListener() {
                @Override
                public void animationChanged(Animatable animator) {
                    DurationAnimatable da = (DurationAnimatable) animator;
                    double progress = da.getPlayedDuration();
                    double alpha = alphaTimeLine.getValueAt((float) progress);
                    noteAlpha.put(note, alpha);
                    repaint();
                }
            }, new AnimatableLifeCycleListenerAdapter() {
                @Override
                public void animationCompleted(Animatable animator) {
                    playing.remove(note);
                    noteAlpha.remove(note);
                    repaint();
                }
            });
            noteAnimatable.start();
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(200, 100);
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2d = (Graphics2D) g.create();

            int startX = 10;
            int endX = getWidth() - 10;
            int range = endX - startX;

            int yPos = getHeight() / 2;

            g2d.setColor(Color.DARK_GRAY);
            g2d.drawLine(startX, yPos, endX, yPos);

            List<Note> notes = musicTimeLine.getValues();
            for (Note note : notes) {
                double potl = musicTimeLine.getPointOnTimeLineFor(note);
                double xPos = startX + (range * potl);
                // Technically, this could be cached...
                Ellipse2D notePoint = new Ellipse2D.Double(xPos - 2.5, yPos - 2.5, 5, 5);
                g2d.fill(notePoint);

                if (noteAlpha.containsKey(note)) {
                    double alpha = noteAlpha.get(note);
                    // I'm lazy :/
                    // It's just simpler to copy the current context, modify the
                    // composite, paint and then dispose of, then trying to 
                    // track and reset the composite manually
                    Graphics2D alpha2d = (Graphics2D) g2d.create();
                    alpha2d.setComposite(AlphaComposite.SrcOver.derive((float) alpha));
                    Ellipse2D playedNote = new Ellipse2D.Double(xPos - 5, yPos - 5, 10, 10);
                    alpha2d.setColor(Color.RED);
                    alpha2d.fill(playedNote);
                    alpha2d.dispose();
                }
            }

            double playXPos = startX + (range * playProgress);
            g2d.setColor(Color.RED);
            Line2D playLine = new Line2D.Double(playXPos, 0, playXPos, getHeight());
            g2d.draw(playLine);

            g2d.dispose();
        }

    }

    public class Note {

        private Duration duration;

        public Note(Duration duration) {
            this.duration = duration;
        }

        public Duration getDuration() {
            return duration;
        }
    }

    public static class EventTimeLine<T> {

        private Map<Double, KeyFrame<T>> mapEvents;

        public EventTimeLine() {
            mapEvents = new TreeMap<>();
        }

        public void add(double progress, T value) {
            mapEvents.put(progress, new KeyFrame<T>(progress, value));
        }

        public List<T> getValues() {
            return Collections.unmodifiableList(mapEvents.values().stream()
                    .map(kf -> kf.getValue())
                    .collect(Collectors.toList()));
        }

        public double getPointOnTimeLineFor(T value) {
            for (Map.Entry<Double, KeyFrame<T>> entry : mapEvents.entrySet()) {
                if (entry.getValue().getValue() == value) {
                    return entry.getKey();
                }
            }

            return -1;
        }

        public List<T> getValuesAt(double progress) {

            if (progress < 0) {
                progress = 0;
            } else if (progress > 1) {
                progress = 1;
            }

            return getKeyFramesBetween(progress, 0.01f)
                    .stream()
                    .map(kf -> kf.getValue())
                    .collect(Collectors.toList());
        }

        public List<KeyFrame<T>> getKeyFramesBetween(double progress, double delta) {

            int startAt = 0;

            List<Double> keyFrames = new ArrayList<>(mapEvents.keySet());
            while (startAt < keyFrames.size() && keyFrames.get(startAt) <= progress - delta) {
                startAt++;
            }

            startAt = Math.min(keyFrames.size() - 1, startAt);
            int endAt = startAt;
            while (endAt < keyFrames.size() && keyFrames.get(endAt) <= progress + delta) {
                endAt++;
            }
            endAt = Math.min(keyFrames.size() - 1, endAt);

            List<KeyFrame<T>> frames = new ArrayList<>(5);
            for (int index = startAt; index <= endAt; index++) {
                KeyFrame<T> keyFrame = mapEvents.get(keyFrames.get(index));
                if (keyFrame.getProgress() >= progress - delta
                        && keyFrame.getProgress() <= progress + delta) {
                    frames.add(keyFrame);
                }
            }

            return frames;

        }

        public class KeyFrame<T> {

            private double progress;
            private T value;

            public KeyFrame(double progress, T value) {
                this.progress = progress;
                this.value = value;
            }

            public double getProgress() {
                return progress;
            }

            public T getValue() {
                return value;
            }

            @Override
            public String toString() {
                return "KeyFrame progress = " + getProgress() + "; value = " + getValue();
            }

        }

    }

    public static class BlendingTimeLine<T> {

        private Map<Float, KeyFrame<T>> mapEvents;

        private Blender<T> blender;

        public BlendingTimeLine(Blender<T> blender) {
            mapEvents = new TreeMap<>();
            this.blender = blender;
        }

        public void setBlender(Blender<T> blender) {
            this.blender = blender;
        }

        public Blender<T> getBlender() {
            return blender;
        }

        public void add(float progress, T value) {
            mapEvents.put(progress, new KeyFrame<T>(progress, value));
        }

        public T getValueAt(float progress) {
            if (progress < 0) {
                progress = 0;
            } else if (progress > 1) {
                progress = 1;
            }

            List<KeyFrame<T>> keyFrames = getKeyFramesBetween(progress);

            float max = keyFrames.get(1).progress - keyFrames.get(0).progress;
            float value = progress - keyFrames.get(0).progress;
            float weight = value / max;

            T blend = blend(keyFrames.get(0).getValue(), keyFrames.get(1).getValue(), 1f - weight);
            return blend;
        }

        public List<KeyFrame<T>> getKeyFramesBetween(float progress) {

            List<KeyFrame<T>> frames = new ArrayList<>(2);
            int startAt = 0;
            Float[] keyFrames = mapEvents.keySet().toArray(new Float[mapEvents.size()]);
            while (startAt < keyFrames.length && keyFrames[startAt] <= progress) {
                startAt++;
            }

            startAt = Math.min(startAt, keyFrames.length - 1);

            frames.add(mapEvents.get(keyFrames[startAt - 1]));
            frames.add(mapEvents.get(keyFrames[startAt]));

            return frames;

        }

        protected T blend(T start, T end, float ratio) {
            return blender.blend(start, end, ratio);
        }

        public static interface Blender<T> {

            public T blend(T start, T end, float ratio);
        }

        public class KeyFrame<T> {

            private float progress;
            private T value;

            public KeyFrame(float progress, T value) {
                this.progress = progress;
                this.value = value;
            }

            public float getProgress() {
                return progress;
            }

            public T getValue() {
                return value;
            }

            @Override
            public String toString() {
                return "KeyFrame progress = " + getProgress() + "; value = " + getValue();
            }

        }

    }

    public class DoubleBlender implements BlendingTimeLine.Blender<Double> {

        @Override
        public Double blend(Double start, Double end, float ratio) {
            double ir = (double) 1.0 - ratio;
            return (double) (start * ratio + end * ir);
        }

    }

    public enum Animator {
        INSTANCE;
        private Timer timer;
        private List<Animatable> properies;

        private Animator() {
            properies = new ArrayList<>(5);
            timer = new Timer(5, new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    List<Animatable> copy = new ArrayList<>(properies);
                    Iterator<Animatable> it = copy.iterator();
                    while (it.hasNext()) {
                        Animatable ap = it.next();
                        ap.tick();
                    }
                    if (properies.isEmpty()) {
                        timer.stop();
                    }
                }
            });
        }

        public void add(Animatable ap) {
            properies.add(ap);
            timer.start();
        }

        protected void removeAll(List<Animatable> completed) {
            properies.removeAll(completed);
        }

        public void remove(Animatable ap) {
            properies.remove(ap);
            if (properies.isEmpty()) {
                timer.stop();
            }
        }

    }

    // Reprepresents a linear animation
    public interface Animatable {

        public void tick();

        public void start();

        public void stop();
    }

    public interface DurationAnimatable extends Animatable {
        public Duration getDuration();
        public Double getPlayedDuration();
    }

    public abstract class AbstractAnimatable implements Animatable {

        private AnimatableListener animatableListener;
        private AnimatableLifeCycleListener lifeCycleListener;

        public AbstractAnimatable(AnimatableListener listener) {
            this(listener, null);
        }

        public AbstractAnimatable(AnimatableListener listener, AnimatableLifeCycleListener lifeCycleListener) {
            this.animatableListener = listener;
            this.lifeCycleListener = lifeCycleListener;
        }

        public AnimatableLifeCycleListener getLifeCycleListener() {
            return lifeCycleListener;
        }

        public AnimatableListener getAnimatableListener() {
            return animatableListener;
        }

        @Override
        public void tick() {
            fireAnimationChanged();
        }

        @Override
        public void start() {
            fireAnimationStarted();
            Animator.INSTANCE.add(this);
        }

        @Override
        public void stop() {
            fireAnimationStopped();
            Animator.INSTANCE.remove(this);
        }

        protected void fireAnimationChanged() {
            if (animatableListener == null) {
                return;
            }
            animatableListener.animationChanged(this);
        }

        protected void fireAnimationStarted() {
            if (lifeCycleListener == null) {
                return;
            }
            lifeCycleListener.animationStarted(this);
        }

        protected void fireAnimationStopped() {
            if (lifeCycleListener == null) {
                return;
            }
            lifeCycleListener.animationStopped(this);
        }

    }

    public interface AnimatableListener {

        public void animationChanged(Animatable animator);
    }

    public interface AnimatableLifeCycleListener {

        public void animationCompleted(Animatable animator);

        public void animationStarted(Animatable animator);

        public void animationPaused(Animatable animator);

        public void animationStopped(Animatable animator);
    }

    public class AnimatableLifeCycleListenerAdapter implements AnimatableLifeCycleListener {

        @Override
        public void animationCompleted(Animatable animator) {
        }

        @Override
        public void animationStarted(Animatable animator) {
        }

        @Override
        public void animationPaused(Animatable animator) {
        }

        @Override
        public void animationStopped(Animatable animator) {
        }

    }

    public class DefaultDurationAnimatable extends AbstractAnimatable implements DurationAnimatable {

        private Duration duration;
        private Instant startTime;

        public DefaultDurationAnimatable(Duration duration, AnimatableListener listener, AnimatableLifeCycleListener lifeCycleListener) {
            super(listener, lifeCycleListener);
            this.duration = duration;
        }

        @Override
        public Duration getDuration() {
            return duration;
        }

        @Override
        public Double getPlayedDuration() {
            if (startTime == null) {
                return 0.0;
            }
            Duration duration = getDuration();
            Duration runningTime = Duration.between(startTime, Instant.now());
            double progress = (runningTime.toMillis() / (double) duration.toMillis());

            return Math.min(1.0, Math.max(0.0, progress));
        }

        @Override
        public void tick() {
            if (startTime == null) {
                startTime = Instant.now();
                fireAnimationStarted();
            }
            fireAnimationChanged();
            if (getPlayedDuration() >= 1.0) {
                fireAnimationCompleted();
                stop();
            }
        }

        protected void fireAnimationCompleted() {
            AnimatableLifeCycleListener lifeCycleListener = getLifeCycleListener();
            if (lifeCycleListener == null) {
                return;
            }
            lifeCycleListener.animationCompleted(this);
        }

    }

}

Yes it "seems" complicated, yes it "seems" hard. But when you've done this kind of thing a few times, it becomes simpler and the solution makes a lot more sense.

It's decoupled. It's re-usable. It's flexible.

In this example, I've mostly used paintComponent as the main rendering engine. But you could just as easily use individual components linked together with some kind of event driven framework.

MadProgrammer
  • 343,457
  • 22
  • 230
  • 366
  • @HovercraftFullOfEels One day I might even get a library built from it all :P – MadProgrammer Oct 24 '18 at 00:00
  • I assumed that you already had one -- GitHub perhaps? Hopefully well M-V-C'd so that you can swap out the Swing for FX or Android. – Hovercraft Full Of Eels Oct 24 '18 at 01:42
  • @HovercraftFullOfEels I've been mucking about with one, but each time I think I'm on track, a question like this comes a long to highlight some of the limitations :P – MadProgrammer Oct 24 '18 at 01:44
  • My daughter's *exact* words to me this weekend: "Dad, don't let perfect get in the way of good enough" – Hovercraft Full Of Eels Oct 24 '18 at 01:46
  • 1
    @HovercraftFullOfEels "Good enough is close enough" Think I've been in that meeting – MadProgrammer Oct 24 '18 at 02:10
  • @HovercraftFullOfEels And procrastination wins [SuperSimpleSwingAnimationFramework](https://github.com/RustyKnight/SuperSimpleSwingAnimationFramework) – MadProgrammer Oct 24 '18 at 05:19
  • @MadProgrammer Big thanks for this! You've provided a very helpful tutorial here. This is actually all for a project I'm working on for my college and since I'm pretty clueless about animation in Java, I was just replacing each image with a "highlighted" version of it, which is just the same image (of the note) with a swapped out colour palette (green). So when the player "went over" that note, it would turn green and look like it's being animated, but it isn't. I needed the timer thing working so that it would spend an appropriate amount of time on each note before moving onto the next. – Pawan Bhandarkar Oct 24 '18 at 14:44
  • @PawanBhandarkar It's still the same basic idea. When the key frame is triggered, you switch the image. You could then use a second key frame or a `Timer` to switch the image back at some point in the future – MadProgrammer Oct 24 '18 at 19:30
  • 1
    @PawanBhandarkar Just beware that multiple timers has a degrading performance on the system, which is why I use a single timer based engine – MadProgrammer Oct 24 '18 at 20:32