Project Onyx aims to provide blueprints for animated Swing applications powered by the Trident animation library. It displays scrollable cover art for the specific artist, and allows viewing track listing for the selected album. When i first introduced Onyx a few months ago, it fetched the album information from the Amazon backend. Recently Amazon has decided to further tighten the requirements for applications connecting to its backend, and in addition to using application keys, it also requires that all requests are authenticated using signatures.

New Onyx screens

As this places even more obstacles for people interested in trying Onyx locally (or remotely, for that matter – since i don’t want to publish the keys that i was using), i started looking for an alternative backend provider. My first stop was Yahoo! Music API, but after reading the documentation i found that in addition to requiring an application key it also returns very small cover art. My next stop was Last.fm Web Services. Here, in addition to requiring an application key, the main limitation seems to be a lack of an API to retrieve the track listing for a specific album.

Finally, a helpful thread at StackOverflow pointed me to the MusicBrainz service. After reading the documentation and trying out the examples – without the need for any application key – i have migrated the Onyx codebase to use MusicBrainz. The best part is that since there is no key, Onyx can now be WebStarted by clicking the button below – just make sure that you have Java 7 installed since it uses the new Window APIs for translucent and shaped windows:

The backend connector for MusicBrainz is quite simple, and it uses the JDOM library to parse the XML responses. There are a few notable issues with MusicBrainz that still don’t make it the best choice to fetch album information from the web:

  • In general, content is user supplied and not vetted for completeness.
  • Multiple entries for the same albums – need to be filtered out based on artist/title or ASIN.
  • Track entries for some albums are named [data track].
  • Only XML format supported in replies.
  • No ability to sort results based on album attributes (such as release date).
  • Cover art is not available. Needs to be fetched using “magically” crafted URLs from Amazon.

In the next few days i’m going to move Project Granite – the SWT counterpart of Onyx – to use MusicBrainz as well. In the meantime, browse the updated Onyx code, run the demo above and enjoy the animations powered by Trident. Release 1.1 is just around the corner.

Concluding the series on adding animations to enable rich interactivity expected from modern SWT applications, here is what we have seen so far:

  • Part 1 – adding simple animation behavior to such scenarios as component appearance (fade in) and window disposal (fade-out) using built in and custom class attributes and setters.
  • Part 2 – adding animated load progress indication while the application is loading data.
  • Part 3 – loading the album art matching the specific search string and asynchronously displaying the associated images.
  • Part 4 – scrolling the album covers showed in the container and adding animations to the scrolling.
  • Part 5 – complex transition scenarios.

How can you run this code locally?

  • Get the latest SVN snapshots of Trident and Granite
  • Add the following Eclipse libraries to the project: org.eclipse.core.jobs, org.eclipse.equinox.common, org.eclipse.osgi
  • The Granite distribution contains the lib/amazon.jar. It has been created with the following steps:
    • wsimport -d ./build -s ./src -p com.ECS.client.jax http://ecs.amazonaws.com/AWSECommerceService/AWSECommerceService.wsdl .
    • jar cvf ../amazon.jar .
  • Get an Amazon E-commerce key
  • Run the org.pushingpixels.granite.DemoApp class, passing your Amazon key as the only parameter to this class, adding the Amazon, Trident and Granite classes, as well as Eclipse jars to the classpath

If all went right, you should see the main application running and displaying Sarah McLachlan albums as in this video:

I hope you enjoyed this series. If you’re interested in adding rich animations to your SWT applications, you’re more than welcome to explore Trident and Granite and report any bugs and missing features in the project forums and mailing lists.

After adding such animation effects as fading, load progress, asynchronous load of images and smooth scrolling in the application window connected to the Amazon backend, it’s time to talk about more complex transition scenarios. In this entry i’m going to talk about displaying larger album art and scrollable track listing when the specific album is selected, along with a complex transition between selected albums. This code is part of the Granite project which aims to provide blueprints for animated SWT applications powered by the Trident animation library.

Here is a screenshot that illustrates the detailed view of the selected album (and you can view the videos in the first part of this series):

https://pushingpixels.dev.java.net/images/granite/albumdetails.png

The full sources of this view are in the SVN repository, and i’m going to talk about the full transition scenario that is played when the user selects a specific album. This scenario has six steps:

  1. (Relevant when the details window already shows album art) – collapse the album art component and track listing component to fully overlap.
  2. (In parallel with step 1) – load the new album art from the Internet (based on the URL returned from the original Amazon E-commerce request).
  3. (After steps 1 and 2 have both completed) – set the loaded album art on the album art component. This may also cause resizing the album art if it cannot fully fit in the available space.
  4. (In parallel with step 3) – set the list of album tracks on the track listing component.
  5. (After steps 3 and 4 have both completed) – cross fade the old album art to the new album art.
  6. (After step 5 has been completed) – move the album art component (that displays the new album art) and the track listing to be displayed side by side.

To implement this complex timeline scenario, the code uses the rendezvous timeline scenario provided by Trident. Timeline.RendezvousSequence allows simple branch-and-wait ordering. The rendezvous scenario has a stage-like approach. All actors belonging to the same stage run in parallel, while actors in stage N+1 wait for all actors in stage N to be finished. The RendezvousSequence.rendezvous marks the end of one stage and the beginning of another.

Here is how the code looks like:

   /**
    * Returns the timeline scenario that implements a transition from the
    * currently shown album item (which may be null) to the
    * specified album item.
    *
    * @param albumItem
    *            The new album item to be shown in this window.
    * @return The timeline scenario that implements a transition from the
    *         currently shown album item (which may be null) to
    *         the specified album item.
    */
   private TimelineScenario getShowAlbumDetailsScenario(final Item albumItem) {
      TimelineScenario.RendezvousSequence scenario = new TimelineScenario.RendezvousSequence();

      // step 1 - move album art and track listing to the same location
      Timeline collapseArtAndTracks = new Timeline(this);
      collapseArtAndTracks.addPropertyToInterpolate("overlayPosition",
            this.overlayPosition, 0.0f);
      collapseArtAndTracks.addCallback(new UIThreadTimelineCallbackAdapter() {
         int startingRegionWidth;

         @Override
         public void onTimelineStateChanged(TimelineState oldState,
               TimelineState newState, float durationFraction,
               float timelinePosition) {
            if (newState == TimelineState.READY) {
               startingRegionWidth = (int) (BigAlbumArt.TOTAL_DIM * (1 + overlayPosition));
            }
            updateShellRegion(timelinePosition);
         }

         @Override
         public void onTimelinePulse(float durationFraction,
               float timelinePosition) {
            updateShellRegion(timelinePosition);
         }

         private void updateShellRegion(float timelinePosition) {
            int regionWidth = (int) (startingRegionWidth + timelinePosition
                  * (BigAlbumArt.TOTAL_DIM - startingRegionWidth));
            Region newRegion = new Region();
            newRegion.add(BigAlbumArt.TOTAL_DIM - regionWidth / 2, 0,
                  regionWidth, BigAlbumArt.TOTAL_DIM);
            Shell shell = getShell();
            if (!shell.isDisposed())
               getShell().setRegion(newRegion);
         }
      });
      collapseArtAndTracks.setDuration((int) (500 * this.overlayPosition));
      scenario.addScenarioActor(collapseArtAndTracks);

      // step 2 (in parallel) - load the new album art
      final Image[] albumArtHolder = new Image[1];
      EclipseJobTimelineScenarioActor loadNewAlbumArt = new EclipseJobTimelineScenarioActor(
            "Load album art") {
         @Override
         protected org.eclipse.core.runtime.IStatus run(
               org.eclipse.core.runtime.IProgressMonitor arg0) {
            try {
               URL url = new URL(albumItem.getLargeImage().getURL());
               albumArtHolder[0] = new Image(Display.getDefault(), url
                     .openStream());
               return Status.OK_STATUS;
            } catch (Throwable t) {
               t.printStackTrace();
               return Status.CANCEL_STATUS;
            }
         }
      };
      scenario.addScenarioActor(loadNewAlbumArt);
      scenario.rendezvous();

      // step 3 (wait for steps 1 and 2) - replace album art
      TimelineRunnable replaceAlbumArt = new TimelineRunnable() {
         @Override
         public void run() {
            albumArt.setAlbumArtImage(albumArtHolder[0]);
         }
      };
      scenario.addScenarioActor(replaceAlbumArt);

      // step 4 (in parallel) - replace the track listing
      TimelineRunnable replaceTrackListing = new TimelineRunnable() {
         @Override
         public void run() {
            trackListing.setAlbumItem(albumItem);
         }
      };
      scenario.addScenarioActor(replaceTrackListing);
      scenario.rendezvous();

      // step 5 (wait for steps 3 and 4) - cross fade album art from old to
      // new
      Timeline albumArtCrossfadeTimeline = new Timeline(this.albumArt);
      albumArtCrossfadeTimeline.addPropertyToInterpolate("oldImageAlpha",
            255, 0);
      albumArtCrossfadeTimeline
            .addPropertyToInterpolate("imageAlpha", 0, 255);
      albumArtCrossfadeTimeline.addCallback(new SWTRepaintCallback(
            this.albumArt));
      albumArtCrossfadeTimeline.setDuration(400);

      scenario.addScenarioActor(albumArtCrossfadeTimeline);
      scenario.rendezvous();

      // step 6 (wait for step 5) - move new album art and track listing to
      // be side by side.
      Timeline separateArtAndTracks = new Timeline(this);
      separateArtAndTracks.addPropertyToInterpolate("overlayPosition", 0.0f,
            1.0f);
      separateArtAndTracks.addCallback(new UIThreadTimelineCallbackAdapter() {
         @Override
         public void onTimelinePulse(float durationFraction,
               float timelinePosition) {
            updateShellRegion(timelinePosition);
         }

         @Override
         public void onTimelineStateChanged(TimelineState oldState,
               TimelineState newState, float durationFraction,
               float timelinePosition) {
            updateShellRegion(timelinePosition);
         }

         private void updateShellRegion(float timelinePosition) {
            int regionWidth = (int) (BigAlbumArt.TOTAL_DIM * (1.0 + timelinePosition));
            Region newRegion = new Region();
            newRegion.add(BigAlbumArt.TOTAL_DIM - regionWidth / 2, 0,
                  regionWidth, BigAlbumArt.TOTAL_DIM);
            Shell shell = getShell();
            if (!shell.isDisposed())
               getShell().setRegion(newRegion);
         }
      });
      separateArtAndTracks.setDuration(500);
      scenario.addScenarioActor(separateArtAndTracks);

      return scenario;
   }

This scenario uses the full capabilities offered by the Trident timeline scenarios which allow combining multiple timeline scenario actors in a parallel, sequential or custom order. This code uses the following types of timeline scenario actors:

The only special case not covered by the core Trident APIs is seen in steps 1 and 6. Since SWT does not yet support per-pixel translucency on the shell level (see bug 285026), these steps use Shell.setRegion() API to emulate expanding / collapsing panels. This can also be achieved by changing the size and location of the relevant shell, but i have found changing the region to perform better on my machine. Trident core library does not provide a built-in interpolator for the Region class, and this is why steps 1 and 6 register a custom callback on the relevant timelines.

The rest of the code is pretty straightforward. It defines the components for album art and track listing, as well as the float position of the overlay between them (during the collapse / expand steps):

   /**
    * Component that shows the album art.
    */
   private BigAlbumArt albumArt;

   /**
    * Component that shows the scrollable list of album tracks.
    */
   private TrackListing trackListing;

   /**
    * 0.0f - the album art and track listing are completely overlayed, 1.0f -
    * the album art and track listing are completely separate. Is updated in
    * the {@link #currentShowAlbumDetailsScenario}.
    */
   private float overlayPosition;

When these components are added, we make sure that the album art is displayed on top of the track listing (during the collapse stage). In addition, we install a custom layout manager that respects the current value of the overlayPosition field:

      this.setLayout(new Layout() {
         @Override
         protected void layout(Composite composite, boolean flushCache) {
            int w = composite.getBounds().width;
            int h = composite.getBounds().height;

            // respect the current overlay position to implement the sliding
            // effect in steps 1 and 6 of currentShowAlbumDetailsScenario
            int dim = BigAlbumArt.TOTAL_DIM;
            int dx = (int) (overlayPosition * dim / 2);
            albumArt.setBounds((w - dim) / 2 - dx, (h - dim) / 2, dim, dim);
            trackListing.setBounds((w - dim) / 2 + dx, (h - dim) / 2, dim,
                  dim);
         }

         @Override
         protected Point computeSize(Composite composite, int wHint,
               int hHint, boolean flushCache) {
            return new Point(wHint, hHint);
         }
      });

The overlayPosition is changed in steps 1 and 6 of the main transition scenario, and the public setter requests the container to layout its contents:

   /**
    * Sets the new overlay position of the album art and track listing. This
    * method will also cause revalidation of the main window content pane.
    *
    * @param overlayPosition
    *            The new overlay position of the album art and track listing.
    */
   public void setOverlayPosition(float overlayPosition) {
      this.overlayPosition = overlayPosition;
      this.layout(new Control[] { albumArt, trackListing });
   }

Finally, the scenario itself is created and played when the mouse listener installed on the album overview component detects a mouse click and calls the setAlbumItem API:

   /**
    * Signals that details of the specified album item should be displayed in
    * this window. Note that this window can already display another album item
    * when this method is called.
    *
    * @param albumItem
    *            New album item to show in this window.
    */
   public void setAlbumItem(Item albumItem) {
      if (this.currentShowAlbumDetailsScenario != null)
         this.currentShowAlbumDetailsScenario.cancel();

      this.currentShowAlbumDetailsScenario = this
            .getShowAlbumDetailsScenario(albumItem);
      this.currentShowAlbumDetailsScenario.play();
   }

The rest of the code in this package is very similar to the code examples showed earlier, including custom painting that respects the alpha values, fading out on dispose, translucent window etc.

The final code sample shows how the album details panel is shown. Here, we use a separate Shell placed alongside the bottom edge of the main application window. This shell will have its region animated to emulate expanding / collapsing panels.

currentlyShownWindow = new Shell(mainWindow, SWT.NO_TRIM
      | SWT.DOUBLE_BUFFERED);
currentlyShownWindow.setLayout(new FillLayout());
// place the details window centered along the bottom edge of the
// main application window
Point mainWindowLoc = mainWindow.getLocation();
Point mainWindowDim = mainWindow.getSize();
int x = mainWindowLoc.x + mainWindowDim.x / 2 - BigAlbumArt.TOTAL_DIM;
int y = mainWindowLoc.y + mainWindowDim.y - BigAlbumArt.TOTAL_DIM / 2;
currentlyShownWindow.setSize(2 * BigAlbumArt.TOTAL_DIM,
      BigAlbumArt.TOTAL_DIM);
Region region = new Region();
region.add(BigAlbumArt.TOTAL_DIM / 2, 0, BigAlbumArt.TOTAL_DIM,
      BigAlbumArt.TOTAL_DIM);
currentlyShownWindow.setRegion(region);
region.dispose();
currentlyShownWindow.setLocation(x, y);

currentlyShownWindow.setAlpha(0);
currentlyShownWindow.setVisible(true);
currentlyShownContentPanel = new DetailsContentPanel(
      currentlyShownWindow);
currentlyShownContentPanel.setAlbumItem(albumItem);
currentlyShownWindow
      .layout(new Control[] { currentlyShownContentPanel });

Timeline showWindow = new Timeline(currentlyShownWindow);
showWindow.addPropertyToInterpolate("alpha", 0, 255);
showWindow.setDuration(500);
showWindow.play();

What happens here?

  • Create a new shell and position it in the required location.
  • Set the region to clip the rightmost and leftmost quarters.
  • Set its alpha to 0 (it will be gradually faded in).
  • Set the album item, initiating the transition scenario described above.
  • Create and play the timeline that fades in this window.

After adding such animation effects as fading, load progress and asynchronous load of images in the application window connected to the Amazon backend, it’s time to talk about smooth scrolling of the display results. In this entry i’m going to talk about loading the album art matching the specific search string and asynchronous display of the associated images. This code is part of the Granite project which aims to provide blueprints for animated SWT applications powered by the Trident animation library.

Here is a screenshot that illustrates the search results displayed as album art (and you can view the videos in the first part of this series):

The album items can be scrolled with right / left arrow keys, as well as by using the mouse wheel.

The base implementation of the layout does not use animations on the scrolling, but lays out the groundwork. It starts off by having the list of all album items, and the position of the currently leading item:

public class Stage2Components extends Stage1LoadingProgress {
   /**
    * The list of album overview components. Each component added with
    * {@link #addOverviewComp(Item, ActivationCallback)} is added to this list.
    */
   List comps;

   /**
    * Indicates which album overview component is displayed at the left edge of
    * this container. Note that while this specific class (in its
    * {@link #scrollToNext()} and {@link #scrollToPrevious()}) operate on the
    * integer values, the animated scrolling will result in fractional values
    * of the leading position.
    *
    * At the beginning the value is 0.0 - displaying the first entry in
    * {@link #comps} at the left edge. When scrolling to the next album, the
    * value will become 1.0 (effectively pushing the first album over the left
    * edge). If the scrolling is animated, this value will be gradually
    * interpolated from 0.0 to 1.0.
    *
    * This value is respected in the layout manager to provide the seamless
    * scroll animation.
    *
    */
   float leadingPosition;

   /**
    * Contains all the album components.
    */
   Composite albumComponentsHolder;

In this class, the leadingPosition will only have discrete integer values – immediate scrolling. However, we define is as float for the animation purposes (see below).

The constructor of this class creates the list for holding the album items, as well as registering relevant mouse wheel listener, input map and action map:

   /**
    * Creates the new container that can host album overview components.
    *
    * @param composite
    *            Parent composite.
    */
   public Stage2Components(Composite composite) {
      super(composite);
      this.comps = new ArrayList();

      this.albumComponentsHolder = new Composite(this, SWT.TRANSPARENT);
      this.progressIndicator.moveAbove(this.albumComponentsHolder);

      // register the mouse wheel listener for scrolling content
      composite.addMouseWheelListener(new MouseWheelListener() {
         @Override
         public void mouseScrolled(MouseEvent e) {
            if (e.count < 0) {
               // next
               scrollToNext();
            } else {
               // previous
               scrollToPrevious();
            }
         }
      });

      // use arrow keys for scrolling
      composite.getDisplay().addFilter(SWT.KeyUp, new Listener() {
         @Override
         public void handleEvent(Event event) {
            // only scroll when the details window is not showing
            if (event.keyCode == SWT.ARROW_RIGHT) {
               scrollToNext();
            }
            if (event.keyCode == SWT.ARROW_LEFT) {
               scrollToPrevious();
            }
         }
      });
   }

Next, a method to add a single album to this container:

   /**
    * Adds the specified album item to this album container.
    *
    * @param albumItem
    *            Description of the album item from the Amazon backend.
    * @return Thew matching album overview component.
    */
   public synchronized AlbumOverviewComponent addAlbumItem(Item albumItem) {
      AlbumOverviewComponent comp = new AlbumOverviewComponent(
            this.albumComponentsHolder, albumItem);
      this.comps.add(comp);
      this.albumComponentsHolder.layout(new Control[] { comp });
      return comp;
   }

And the implementation of discrete scrolling:

 
   /**
    * Scrolls the albums to show the next album.
    */
   protected void scrollToNext() {
      if (this.leadingPosition < (this.comps.size() - 1)) {
         this.leadingPosition++;
         this.albumComponentsHolder.layout(true);
      }
   }

   /**
    * Scrolls the albums to show the previous album.
    */
   protected void scrollToPrevious() {
      if (this.leadingPosition > 0) {
         this.leadingPosition--;
         this.albumComponentsHolder.layout(true);
      }
   }

The layout itself (triggered by the calls to layout() above) is quite simple – computing the X position of each album item based on the current leadingPosition:

      // set custom layout manager to arrange the row of album
      // components
      this.albumComponentsHolder.setLayout(new Layout() {
         @Override
         protected Point computeSize(Composite composite, int wHint,
               int hHint, boolean flushCache) {
            return new Point(wHint, hHint);
         }

         @Override
         protected void layout(Composite composite, boolean flushCache) {
            if (comps.size() == 0)
               return;

            for (int i = 0; i < comps.size(); i++) {
               float delta = i - leadingPosition;
               // compute the left X based on the current leading position
               int x = (int) (delta * (AlbumOverviewComponent.DEFAULT_WIDTH + 10));
               comps
                     .get(i)
                     .setBounds(
                           x,
                           (getBounds().height - AlbumOverviewComponent.DEFAULT_HEIGHT) / 2,
                           AlbumOverviewComponent.DEFAULT_WIDTH,
                           AlbumOverviewComponent.DEFAULT_HEIGHT);
            }
         }
      });

Now it’s time to add the scrolling animation to the mix. With the groundwork laid out already, the implementation is quite simple.

public class Stage3AnimatedScrolling extends Stage2Components {
   /**
    * Contains the target leading position - this is the index of the album
    * which should appear at the left edge once the current
    * {@link #scrollTimeline} is done. Note that the user scrolling can be done
    * in the middle of the current scrolling animation. In this case, the field
    * is updated with the new target index.
    */
   float targetLeadingPosition;

   /**
    * The scroll timeline. Note that the user scrolling can be done in the
    * middle of the current scrolling animation. In this case, the current
    * timeline is cancelled, and a new one is created - this allows quick
    * scrolling with multiple mouse wheel / arrow events.
    */
   Timeline scrollTimeline;

The targetLeadingPosition allows us to handle multiple consecutive scrolling requests (user quickly scrolling the mouse wheel) without having multiple timelines “competing” to scroll the album items.

The constructor doesn’t do anything special:

   /**
    * Creates the new container that can animate the album scrolling.
    *
    * @param composite
    *            Parent composite.
    */
   public Stage3AnimatedScrolling(Composite composite) {
      super(composite);
      this.targetLeadingPosition = 0;
   }

Now we get to the “meat” of this class – adding the scrolling animations. First, we override the scrolling methods from the super class to update the target position:

   @Override
   protected void scrollToNext() {
      if (this.targetLeadingPosition < (this.comps.size() - 1)) {
         this.targetLeadingPosition++;
         scrollContents();
      }
   }

   @Override
   protected void scrollToPrevious() {
      if (this.targetLeadingPosition > 0) {
         this.targetLeadingPosition--;
         scrollContents();
      }
   }

Where the scrollContents aborts the existing timeline (if necessary), and plays a new one:

   /**
    * Scrolls the contents of this container.
    */
   private synchronized void scrollContents() {
      if (this.scrollTimeline != null) {
         // abort the playing scroll timeline
         this.scrollTimeline.abort();
      }

      // and dynamically create a new one to change the
      // leading position
      this.scrollTimeline = new Timeline(this);
      this.scrollTimeline
            .addPropertyToInterpolate(Timeline. property(
                  "leadingPosition").fromCurrent().to(
                  this.targetLeadingPosition));
      this.scrollTimeline.setDuration(250);

      this.scrollTimeline.play();
   }

Canceling the old timeline makes sure that we will not have multiple timeline updating the leadingPosition field. And the targetLeadingPosition field holds the final value of the leadingPosition – or at least until the user makes an additional scrolling request.

Finally, we have a public setter and getter so that the Trident engine can change the value of the leadingPosition field:

   /**
    * Sets the new value for the leading position. This is called from
    * {@link #scrollTimeline}.
    * 
    * @param leadingPosition
    *            The new value for the leading position.
    */
   public void setLeadingPosition(float leadingPosition) {
      this.leadingPosition = leadingPosition;
      this.albumComponentsHolder.layout(true);
   }

   /**
    * Returns the current value of the leading position. This is called from
    * {@link #scrollTimeline}.
    * 
    * @return The current value of the leading position.
    */
   public float getLeadingPosition() {
      return this.leadingPosition;
   }

Here we have seen how to scroll the album covers showed in the container and how to add animations to the scrolling. The next entry is going to talk about displaying larger album art and scrollable track listing when the specific album is selected.