Chapter     7

Media with JavaFX

JavaFX provides a media-rich API capable of playing audio and video. The Media API allows developers to incorporate audio and video into their rich Internet applications (RIAs). One of the main benefits of the Media API is that it can distribute cross-platform media content via the Web. With a range of devices (tablet, music player, TV, and so on) that need to play multimedia content, the need for a cross-platform API is essential.

Imagine a not-so-distant future where your TV or wall is capable of interacting with you in ways that you’ve never dreamed possible. For instance, while viewing a movie you could select items of clothing used in the movie to be immediately purchased, all from the comfort of your home. With this future in mind, developers seek to enhance the interactive qualities of their media-based applications.

In this chapter you will learn how to play audio and video in an interactive way. Find your seats as audio and video take center stage.

You will be learning the following JavaFX media APIs:

  • javafx.scene.media.Media
  • javafx.scene.media.MediaPlayer
  • javafx.scene.media.MediaStatus
  • javafx.scene.media.MediaView

Media Events

An event-driven architecture (EDA) is a prominent architectural pattern used to model loosely coupled components and services that pass messages asynchronously. The JavaFX team has designed the Media API to be event-driven. In this section you will learn how to interact with media events.

With event-based programming in mind, you will discover nonblocking or callback behaviors when invoking media functions. Instead of tying code directly to a button via an EventHandler, you will be implementing code that will respond to the triggering of the media player’s OnXXXX events, where XXXX is the event name.

When responding to media events, you will implement java.lang.Runnable functional interfaces (lambda expressions). These functional interfaces are callbacks that are lazily evaluated or invoked at a later time upon a triggered event. For instance, when playing media content you would create a lambda expression (closure) to be set on the OnReady event. For an example of playing media based on an event-driven approach, see the following code block:

Media media = new Media(url);
MediaPlayer  mediaPlayer = new MediaPlayer(media);
Runnable playMusic = () -> mediaPlayer.play();
mediaPlayer.setOnReady(playMusic);

As you can see, the playMusic variable is assigned to a lambda expression (Runnable) to be passed into the media player’s setOnReady() method. When the OnReady event has been encountered, the playMusic Runnable closure code would be invoked. Although I have not yet discussed how to set up the Media and MediaPlayer class instances, I wanted to first get you acquainted with these core concepts before moving further, because these concepts are used throughout this chapter.

Shown in Table 7-1 are all the possible media events that are raised to allow the developer to attach Runnables (or EventHandlers). All of the following are Runnable functional interfaces except for the OnMarker event.

Table 7-1. Media and MediaPlayer Events

image

You will notice in Table 7-1 that the MediaPlayer class has the most events that can be triggered, while the Media and MediaView classes have only one event (OnError). You’ll also notice the third column, showing onXXXXProperty() methods. These onXXXXProperty() methods are the same as the setOnXXXX() methods except that they also support property bindings; this gives them the ability to attach change and invalidation listeners. The following code snippet is an example of setting a Runnable (lambda expression) instance using the onXXXXProperty() method instead of the usual setOnXXXX() method:

Media media = new Media(url);
MediaPlayer  mediaPlayer = new MediaPlayer(media);
mediaPlayer.onReadyProperty().set(() -> mediaPlayer.play());

Playing Audio

JavaFX’s media API supports loading audio files with extensions such as .mp3, .wav, and .aiff. Also new in JavaFX 8 is the ability to play audio in HTTP live streaming format, also known as HLS (file extension .m3u8). HLS is beyond the scope of this book and will not be covered in this chapter, but with some further research, JavaFX could be used to build a live radio broadcast application.

Playing audio files is extremely straightforward in JavaFX. Given a valid URL location to a file, you would instantiate a javafx.scene.media.Media class. The Media object is then passed to a new instance of a javafx.scene.media.MediaPlayer object. The last step is to invoke the media player object’s play() method when the OnReady event is triggered. The following code loads and plays an MP3 audio file located on a web server:

Media media = new Media("http://some_host/eye_on_it.mp3");
MediaPlayer  mediaPlayer = new MediaPlayer(media);
mediaPlayer.setAutoPlay(true);

Note  When loading media files, make sure the file location is a formatted string that follows the standard URL specification.

The media file can reside on a web server, in a jar file, or a local file system as long as the filename string is formatted to follow the standard URL specification.

You will also notice the code’s last statement, which calls the method setAutoPlay(true). Calling the setAutoPlay() is really a shortcut for setting the setOnReady() event handler method with a Runnable functional interface. Both techniques will result in playing the audio file once the MediaPlayer instance is in a ready state.

Note  For low-latency playback of audio files, use the javafx.scene.media.AudioClip class. A typical scenario is to play a given sound multiple times consecutively, as with sound effects used in games.

An MP3 Player Example

Now that you know how to load and play audio media, let’s look at a fun example that involves playing music and displaying a colorful visualization. In this section you will learn how to create an MP3 audio player. Before we explore the example’s code in Listing 7-1, let’s look at Figure 7-1 for a preview of our audio player’s UI.

9781430264606_Fig07-01.jpg

Figure 7-1. Our example JavaFX audio player

In this example we will again use the drag-and-drop metaphor, as we did in Chapter 5 with image files. In the same manner as in the earlier example, the user navigates to the local file system or a browser URL address to locate a media file to be dragged and dropped onto the application. In the case of our MP3 player, you will locate an audio file with a file extension of .mp3, .wav or .aif to be used in this example.

As you can see in Figure 7-1, our MP3 player displays randomly colored circles bouncing around based on the media’s audio spectrum information (its phase array). The audio player has a custom button panel control located in the bottom-right corner. The button panel control lets the user pause, resume, and stop the playing of music. You’ll also notice the seek position slider (aka the progress and seek position slider) on the bottom-left side, which allows the user to seek backward or forward in the media (audio or video).

The Stop, Play, and Pause Buttons

The left image in Figure 7-2 shows Stop (square) and Play (triangle wedge) buttons. When the Stop button is pressed, the media is repositioned to the beginning. When the Pause button is pressed, the media will maintain its current position within the media playback. Also, as the media is paused, the Play button will appear, so that the user can resume the music. The right image in Figure 7-2 shows the button panel containing a Stop and a Pause button (circle with two vertical lines). While the music is currently playing, the user can press the Pause button to pause the music.

9781430264606_Fig07-02.jpg

Figure 7-2. Custom button panel control having Stop (rectangle), Play (triangle), and Pause (circle) buttons

The Progress and Seek Position Slider Control

Next, you’ll notice the progress and seek position slider control in the lower-left corner of the application window shown in Figure 7-3. Assuming the media is paused, the seek position slider control allows you to move backward or forward into the media’s current time. Also, the slider’s “thumb” will move from left to right as the media play is progressing.

9781430264606_Fig07-03.jpg

Figure 7-3. The Seek Position slider control allows the user to seek backward or forward in the media

The Close Button

Finally, notice the Close button, shown in Figure 7-4, which simply allows the user to quit the application.

9781430264606_Fig07-04.jpg

Figure 7-4. The Close button, located in the upper-right corner, allows the user to quit the application

MP3 Audio Player Source Code

Listing 7-1 is the source code for a JavaFX-based MP3 audio player. The source code from a previous edition of this book was refactored slightly to break things up into methods instead of one large start() method. In addition to the refactoring, I pulled out the code that used the programmatic approach to style JavaFX nodes. Instead of styling JavaFX nodes with setter() methods, I simply created a CSS style sheet. Listing 7-2 shows the CSS style sheet’s selector definitions.

Listing 7-1. An MP3 Audio Player Application (PlayingAudio.java)

package jfx8ibe;
 
import java.net.MalformedURLException;
import java.util.Random;
import javafx.application.*;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Point2D;
import javafx.scene.*;
import javafx.scene.control.Slider;
import javafx.scene.input.*;
import javafx.scene.media.*;
import javafx.scene.media.MediaPlayer.Status;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.scene.text.*;
import javafx.stage.*;
import javafx.util.Duration;
 
/**
 * Chapter 7 Playing Audio using JavaFX media API.
 *
 * @author cdea
 */
public class PlayingAudio extends Application {

private MediaPlayer mediaPlayer;
   private Point2D anchorPt;
   private Point2D previousLocation;
   private ChangeListener<Duration> progressListener;
 
   private static Stage PRIMARY_STAGE;
   private static final String STOP_BUTTON_ID = "stop-button";
   private static final String PLAY_BUTTON_ID = "play-button";
   private static final String PAUSE_BUTTON_ID = "pause-button";
   private static final String CLOSE_BUTTON_ID = "close-button";
   private static final String VIS_CONTAINER_ID = "viz-container";
   private static final String SEEK_POS_SLIDER_ID = "seek-position-slider";
 
   /**
    * @param args the command line arguments
    */
   public static void main(String[] args) {
      Application.launch(args);
   }
 
   @Override
   public void start(Stage primaryStage) {
      PRIMARY_STAGE = primaryStage;
      PRIMARY_STAGE.initStyle(StageStyle.TRANSPARENT);
      PRIMARY_STAGE.centerOnScreen();
 
      Group root = new Group();
      Scene scene = new Scene(root, 551, 270, Color.rgb(0, 0, 0, 0));
 
      // load JavaFX CSS style
      scene.getStylesheets()
           .add(getClass().getResource("playing-audio.css")
                          .toExternalForm());
      PRIMARY_STAGE.setScene(scene);
 
      // Initialize stage to be movable via mouse
      initMovablePlayer();
 
      // application area
      Node applicationArea = createApplicationArea();
 
      // Container for random circles bouncing about
      Node vizContainer = new Group();
      vizContainer.setId(VIS_CONTAINER_ID);
 
      // Create the button panel
      Node buttonPanel = createButtonPanel();
 
      // Progress and seek position slider
      Slider progressSlider = createSlider();
 
      // Update slider as video is progressing
      progressListener = (observable, oldValue, newValue) ->
         progressSlider.setValue(newValue.toSeconds());
 
      // Initializing to accept files
      // dragged over surface to load media
      initFileDragNDrop();
 
      // Create the close button
      Node closeButton = createCloseButton();
 
      root.getChildren()
          .addAll(applicationArea,
                  vizContainer,
                  buttonPanel,
                  progressSlider,
                  closeButton);
 
      primaryStage.show();
   }
 
   /**
    * Initialize the stage to allow the mouse cursor to
    * move the application using dragging.
    *
    */
   private void initMovablePlayer() {
      Scene scene = PRIMARY_STAGE.getScene();
      // starting initial anchor point
      scene.setOnMousePressed(mouseEvent
              -> anchorPt = new Point2D(mouseEvent.getScreenX(),
                      mouseEvent.getScreenY())
      );
 
      // dragging the entire stage
      scene.setOnMouseDragged(mouseEvent -> {
         if (anchorPt != null && previousLocation != null) {
            PRIMARY_STAGE.setX(previousLocation.getX()
                    + mouseEvent.getScreenX()
                    - anchorPt.getX());
            PRIMARY_STAGE.setY(previousLocation.getY()
                    + mouseEvent.getScreenY()
                    - anchorPt.getY());
         }
      });
 
      // set the current location
      scene.setOnMouseReleased(mouseEvent
              -> previousLocation = new Point2D(PRIMARY_STAGE.getX(),
                      PRIMARY_STAGE.getY())
      );
 
// Initialize previousLocation after Stage is shown
      PRIMARY_STAGE.addEventHandler(WindowEvent.WINDOW_SHOWN,
              (WindowEvent t) -> {
                 previousLocation = new Point2D(PRIMARY_STAGE.getX(),
                         PRIMARY_STAGE.getY());
              });
   }
 
   /**
    * A simple rectangular area as the surface of the app.
    * @return Node a Rectangle node.
    */
   private Node createApplicationArea() {
      Scene scene = PRIMARY_STAGE.getScene();
      Rectangle applicationArea = new Rectangle();
      // add selector to style app-area
      applicationArea.setId("app-area");
 
      // make the app area rectangle the size of the scene.
      applicationArea.widthProperty()
              .bind(scene.widthProperty());
      applicationArea.heightProperty()
              .bind(scene.heightProperty());
      return applicationArea;
   }
 
   /**
    * Initialize the Drag and Drop ability for media files.
    *
    */
   private void initFileDragNDrop() {
 
      Scene scene = PRIMARY_STAGE.getScene();
      scene.setOnDragOver(dragEvent -> {
         Dragboard db = dragEvent.getDragboard();
         if (db.hasFiles() || db.hasUrl()) {
            dragEvent.acceptTransferModes(TransferMode.LINK);
         } else {
            dragEvent.consume();
         }
      });
      // Dropping over surface
      scene.setOnDragDropped(dragEvent -> {
         Dragboard db = dragEvent.getDragboard();
         boolean success = false;
         String filePath = null;
         if (db.hasFiles()) {
            success = true;
            if (db.getFiles().size() > 0) {
               try {
                  filePath = db.getFiles()
                          .get(0)
                          .toURI().toURL().toString();
                  playMedia(filePath);
               } catch (MalformedURLException ex) {
                  ex.printStackTrace();
               }
            }
         } else {
            // audio file from some host or jar
            playMedia(db.getUrl());
            success = true;
 
         }
 
         dragEvent.setDropCompleted(success);
         dragEvent.consume();
      }); // end of setOnDragDropped
   }
 
   /**
    * Creates a node containing the audio player's
    *  stop, pause and play buttons.
    *
    * @return Node A button panel having play,
    *  pause and stop buttons.
    */
   private Node createButtonPanel() {
      Scene scene = PRIMARY_STAGE.getScene();
      // create button control panel
      Group buttonGroup = new Group();
 
      // Button area
      Rectangle buttonArea = new Rectangle(60, 30);
      buttonArea.setId("button-area");
 
      buttonGroup.getChildren()
              .add(buttonArea);
 
      // stop button control
      Node stopButton = new Rectangle(10, 10);
      stopButton.setId(STOP_BUTTON_ID);
      stopButton.setOnMousePressed(mouseEvent -> {
         if (mediaPlayer != null) {
            updatePlayAndPauseButtons(true);
            if (mediaPlayer.getStatus() == Status.PLAYING) {
               mediaPlayer.stop();
            }
         }
      }); // setOnMousePressed()
 
      // play button
      Arc playButton = new Arc(12, // center x
 
16, // center y
              15, // radius x
              15, // radius y
              150, // start angle
              60);  // length
      playButton.setId(PLAY_BUTTON_ID);
      playButton.setType(ArcType.ROUND);
      playButton.setOnMousePressed(mouseEvent -> mediaPlayer.play());
 
      // pause control
      Group pauseButton = new Group();
      pauseButton.setId(PAUSE_BUTTON_ID);
      Node pauseBackground = new Circle(12, 16, 10);
      pauseBackground.getStyleClass().add("pause-circle");
 
      Node firstLine = new Line(6,  // start x
                                6,  // start y
                                6,  // end x
                               14); // end y
      firstLine.getStyleClass()
               .add("pause-line");
      firstLine.setStyle("-fx-translate-x: 34;");
 
      Node secondLine = new Line(6,   // start x
                                 6,   // start y
                                 6,   // end x
                                 14); // end y
      secondLine.getStyleClass().add("pause-line");
      secondLine.setStyle("-fx-translate-x: 38;");
 
      pauseButton.getChildren()
           .addAll(pauseBackground, firstLine, secondLine);
 
      pauseButton.setOnMousePressed(mouseEvent -> {
         if (mediaPlayer!=null) {
            updatePlayAndPauseButtons(true);
            if (mediaPlayer.getStatus() == Status.PLAYING) {
               mediaPlayer.pause();
            }
         }
      }); // setOnMousePressed()
 
      playButton.setOnMousePressed(mouseEvent -> {
         if (mediaPlayer != null) {
            updatePlayAndPauseButtons(false);
            mediaPlayer.play();
         }
      }); // setOnMousePressed()
      buttonGroup.getChildren()
                 .addAll(stopButton,
                         playButton,
                         pauseButton);
      // move button group when scene is resized
      buttonGroup.translateXProperty()
                 .bind(scene.widthProperty()
                            .subtract(buttonArea.getWidth() + 6));
      buttonGroup.translateYProperty()
                 .bind(scene.heightProperty()
                            .subtract(buttonArea.getHeight() + 6));
      return buttonGroup;
   }
 
   /**
    * The close button to exit application
    *
    * @return Node representing a close button.
    */
   private Node createCloseButton() {
      Scene scene = PRIMARY_STAGE.getScene();
      Group closeButton = new Group();
      closeButton.setId(CLOSE_BUTTON_ID);
      Node closeBackground = new Circle(5, 0, 7);
      closeBackground.setId("close-circle");
      Node closeXmark = new Text(2, 4, "X");
      closeButton.translateXProperty()
                 .bind(scene.widthProperty()
                            .subtract(15));
      closeButton.setTranslateY(10);
      closeButton.getChildren()
                 .addAll(closeBackground, closeXmark);
      // exit app
      closeButton.setOnMouseClicked(mouseEvent -> Platform.exit());
 
      return closeButton;
   }
 
   /**
    * After a file is dragged onto the application a new MediaPlayer
    * instance is created with a media file.
    *
    * @param stage The stage window (primaryStage)
    * @param url The URL pointing to an audio file
    */
   private void playMedia(String url) {
      Scene scene = PRIMARY_STAGE.getScene();
 
      if (mediaPlayer != null) {
         mediaPlayer.pause();
         mediaPlayer.setOnPaused(null);
         mediaPlayer.setOnPlaying(null);
         mediaPlayer.setOnReady(null);
         mediaPlayer.currentTimeProperty()
                    .removeListener(progressListener);
         mediaPlayer.setAudioSpectrumListener(null);
      }
      Media media = new Media(url);
      // display media's metadata
      for (String s : media.getMetadata().keySet()) {
         System.out.println(s);
      }
      mediaPlayer = new MediaPlayer(media);
 
      // as the media is playing move the slider for progress
      mediaPlayer.currentTimeProperty()
                 .addListener(progressListener);
 
      mediaPlayer.setOnReady(() -> {
         updatePlayAndPauseButtons(false);
         Slider progressSlider =
               (Slider) scene.lookup("#" + SEEK_POS_SLIDER_ID);
         progressSlider.setValue(0);
         progressSlider.setMax(mediaPlayer.getMedia()
                                          .getDuration()
                                          .toSeconds());
         mediaPlayer.play();
      }); // setOnReady()
 
      // back to the beginning
      mediaPlayer.setOnEndOfMedia( ()-> {
         updatePlayAndPauseButtons(true);
         // change buttons to play and rewind
         mediaPlayer.stop();
      }); // setOnEndOfMedia()
 
      // setup visualization (circle container)
      Group vizContainer =
              (Group) PRIMARY_STAGE.getScene()
                                   .lookup("#" + VIS_CONTAINER_ID);
      mediaPlayer.setAudioSpectrumListener(
         (double timestamp,
          double duration,
          float[] magnitudes,
          float[] phases) -> {
            vizContainer.getChildren().clear();
            int i = 0;
            int x = 10;
            double y = PRIMARY_STAGE.getScene().getHeight() / 2;
            Random rand = new Random(System.currentTimeMillis());
            // Build random colored circles
            for (float phase : phases) {
               int red = rand.nextInt(255);
               int green = rand.nextInt(255);
               int blue = rand.nextInt(255);
               Circle circle = new Circle(10);
               circle.setCenterX(x + i);
               circle.setCenterY(y + (phase * 100));
               circle.setFill(Color.rgb(red, green, blue, .70));
               vizContainer.getChildren().add(circle);
               i += 5;
            }
     }); // setAudioSpectrumListener()
 
   }
 
   /**
   * Sets play button visible and pause button not visible when
   * playVisible is true otherwise the opposite.
   *
   * @param playVisible - value of true the play becomes visible
   * and pause non visible, otherwise the opposite.
   */
   private void updatePlayAndPauseButtons(boolean playVisible) {
      Scene scene = PRIMARY_STAGE.getScene();
      Node playButton = scene.lookup("#" + PLAY_BUTTON_ID);
      Node pauseButton = scene.lookup("#" + PAUSE_BUTTON_ID);
 
      // hide or show buttons
      playButton.setVisible(playVisible);
      pauseButton.setVisible(!playVisible);
      if (playVisible) {
         // show play button
         playButton.toFront();
         pauseButton.toBack();
      } else {
         // show pause button
         pauseButton.toFront();
         playButton.toBack();
 
      }
   }
   /**
    * A position slider to seek backward and forward
    * that is bound to a media player control.
    *
    * @return Slider control bound to media player.
    */
   private Slider createSlider() {
   Slider slider = new Slider(0, 100, 1);
   slider.setId(SEEK_POS_SLIDER_ID);
   slider.valueProperty()
         .addListener((observable) -> {
             if (slider.isValueChanging()) {
               // must check if media is paused before seeking
               if (mediaPlayer != null &&
                   mediaPlayer.getStatus() == MediaPlayer.Status.PAUSED) {
  
                  // convert seconds to millis
                  double dur = slider.getValue() * 1000;
                  mediaPlayer.seek(Duration.millis(dur));
               }
             }
         }); //addListener()
      Scene scene = PRIMARY_STAGE.getScene();
      slider.setTranslateX(10);
      slider.translateYProperty()
            .bind(scene.heightProperty()
                       .subtract(50));
      return slider;
   }
}

Listing 7-2. MP3 Audio Player’s CSS style sheet (playing-audio.css)

.root {
    -white-ish: rgba(255, 255, 255, .90);
    -black-ish: rgba(0, 0, 0, .80);
}
#app-area {
    -fx-arc-width: 20;
    -fx-arc-height: 20;
    -fx-fill: -black-ish;
    -fx-stroke: green;
    -fx-stroke-width: 2;
    -fx-stroke: -white-ish;
}
#button-area {
    -fx-arc-width: 15;
    -fx-arc-height: 20;
    -fx-fill: -black-ish;
    -fx-stroke: -white-ish;
}
#stop-button {
    -fx-arc-width: 5;
    -fx-arc-height: 5;
    -fx-fill: -white-ish;
    -fx-translate-x: 15;
    -fx-translate-y: 10;
    -fx-stroke: -white-ish;
}
#play-button {
    -fx-fill: -white-ish;
    -fx-translate-x: 40;
}
.pause-circle {
    -fx-stroke: -white-ish;
    -fx-translate-x: 30;
}
.pause-line {
    -fx-stroke-width: 3;
    -fx-stroke: -white-ish;
    -fx-translate-y: 6;
}
#close-circle {
    -fx-fill: -white-ish;
}
.slider {
    -fx-show-tick-labels: false;
    -fx-show-tick-marks: true;
}
.slider .axis .axis-label {
    -fx-text-fill: white;
}

How It Works

Before we begin I want to describe some changes that I made in this book from the earlier edition. I basically refactored the source code from the prior book’s edition relating to media. Because of this refactoring, I will mention privately scoped methods quite often. These private methods are used to break-up the code into more manageable pieces, so that you can better focus on each feature of the media player application.

We will drag and drop audio files onto the example MP3 player, as we did in Chapter 5’s photo viewer application, in which you learned how to use the drag-and-drop desktop metaphor to load files into a JavaFX application. Instead of image files, however, the user will be loading audio files. To load audio files, JavaFX currently supports the following file formats: mp3, wav, aiff, and m3u8. To see the supported encoding types please refer to the Javadocs documentation of the package summary of javafx.scene.media.

Looking back at Figure 7-2 you might notice that the button panel control has a look and feel similar to the photo viewer application in Figure 5-1 of Chapter 5. In this example, I’ve modified the button controls to resemble buttons, similar to many media player applications.

As an added bonus, the MP3 player will appear as an irregularly shaped, semitransparent window without a title bar and borders. The application window can also be dragged around the desktop using the mouse cursor. Now that you know how to operate the music player, let’s walk through the code.

The Audio Player Application’s Instance Variables

First, the code contains instance variables at the top of the class that will maintain state information for the lifetime of the application. Table 7-2 describes all instance variables used in our MP3 audio player application.

Table 7-2. MP3 Player Application Instance Variables

image

In Table 7-2 the first variable is a reference to a media player (MediaPlayer) object that will be created in conjunction with a Media object containing an audio file. Next, you can see two variables named anchorPt and previousLocation. These variables are used to maintain the stage window’s positions on the desktop when the user drags the application window across the screen.

The anchorPt variable is used to save the starting coordinates of a mouse press when the user begins to drag the window. When calculating the upper-left bounds of the application window during a mouse-drag operation, the previousLocation variable will contain the previous window’s screen X and Y coordinates.

Not listed in Table 7-2 are static variables such as PRIMARY_STAGE, STOP_BUTTON_ID, PLAY_BUTTON_ID, and so on. The static PRIMARY_STAGE variable is a global variable reference as a convenience for all privately scoped methods for easy access to the main stage window. The many static final String variables named XXXX_ID, having an _ID suffix, are node IDs that can be later retrieved via the scene’s lookup() method. For example, if the scene graph contains a node with an id of stop-button (node.setId("stop-button")), you could later invoke the scene.lookup("#stop-button") method to retrieve the Stop button node from the scene graph.

Setting Up the Stage Window

Now that you know about the instance variables, let’s jump into the start() method code. In previous chapters relating to GUIs, you saw that GUI applications normally contain a title bar and windowed borders surrounding the Scene. Here, I want to demonstrate the ability to create irregularly shaped semi-transparent windows. The code invokes the stage (primaryStage) object’s initStyle() method with a StageStyle.TRANSPARENT value; as a result, the stage window has no title bar (is undecorated) and is invisible.

Next, the code centers the stage window on the screen on application startup via the centerOnScreen() method. After centering the stage you’ll notice the usual setup of a scene by applying a JavaFX style sheet file (playing-audio.css). Listing 7-2 shows the contents of the file playing-audio.css. There, you’ll notice that the root class selector has two global color attributes, named -white-ish and -black-ish, created to be reused in other styling definition blocks. If you are not familiar with JavaFX CSS styling, please refer to Chapter 6.

Back in our start() method in Listing 7-1, after applying the CSS style sheet the code invokes the private initMovablePlayer() method, which is responsible for making the stage window draggable. Because you don’t have a native title bar, you will find it convenient to be able to drag any part of the application’s surface. The initMovablePlayer() method makes use of the instance variables anchorPt and previousLocation mentioned earlier, to calculate points based on relative screen coordinates when mouse events (press, drag, or release) are triggered.

Because our scene is invisible, the code will create the application background area by invoking the private method createApplicationArea(). This method returns a simple rounded rectangle as the background of our MP3 audio player application. The returned shape node will be added to the root node of the scene first, and all other nodes added to the root node after that. This order enables all other nodes to sit on top of the background application area.

The Setup Container Node for Visualizations (the Scene.lookup( ) Method)

After creating an application area background (the rounded rectangle), the code creates a container node (Group) that will later hold randomly colored circle shapes. The container node is refreshed with new circles that appear to dance around the application area. Following is the code to create the container node (vizContainer) that provides our visualization area node as audio is being played:

// Container for random circles bouncing about
Node vizContainer = new Group();
vizContainer.setId(VIS_CONTAINER_ID); // vis-container

Did you notice that the id selector was set to viz-container? By setting the id selector, we enable the code to retrieve the container node later via the scene’s lookup() method, as shown here:

Group vizContainer = (Group) stage.getScene().lookup("#viz-container");

This code snippet shows the invocation of the lookup() method of the scene object. You will notice the ’#’ symbol prefixed with the id type selector name. According to the Javadocs documentation "If more than one node matches the specified selector, this function returns the first of them. If no nodes are found with this ID, then null is returned."

Using the scene’s lookup() method facility is a convenient way to retrieve nodes from the scene graph in a random-access way without having to create globally defined instance variables.

Creating a Custom Button Panel

Continuing with our start() method, the code creates a custom button panel by invoking the private method createButtonPanel(). This method creates custom buttons by using JavaFX shapes (rectangles, arcs, circles, and lines) that represent the Stop, Play, and Pause buttons for our MP3 audio player.

When creating shapes or custom nodes, you can add event handlers to nodes in order to respond to mouse events. Although there are advanced ways to build custom controls in JavaFX, I chose to build my own button icons from simple rectangles, arcs, circles, and lines. To see more advanced ways to create custom controls, refer to the Javadocs on the Skinnable API.

To attach event handlers for a mouse press, simply call the setOnMousePress() method by passing in an EventHandler<MouseEvent> instance. The following code block adds a lambda expression that responds to a mouse press for the stopButton node:

Node stopButton = new Rectangle(10, 10);
stopButton.setId(STOP_BUTTON_ID);
stopButton.setOnMousePressed(mouseEvent -> {
   if (mediaPlayer != null) {
      updatePlayAndPauseButtons(true);
      if (mediaPlayer.getStatus() == Status.PLAYING) {
         mediaPlayer.stop();
      }
   }
}); // setOnMousePressed()

In this code, a square node is defined as our Stop button, set with an id of the static String variable STOP_BUTTON_ID, which is really the string value stop-button. Next, the code sets an event handler for the OnMousePressed event to stop the media player from playing. You’ll notice the method call to updatePlayAndPauseButtons(true), which toggles the display of the Play button and hiding of the Pause button. Otherwise, we pass in a boolean value of false, so the Pause button will be displayed and the Play button will be hidden.

Simply put, the media player object is a state machine that transitions between states internally. Some methods use other threads that aren’t on the JavaFX application thread. So it is typically good practice to query the state of the media player before invoking methods such as the stop(). In the case of the Stop button, you’ve seen that the code checks for the Status.PLAYING before invoking the stop() method on the media player object.

Because all the buttons use code similar to the preceding code snippet (EventHandler), I will list only the method calls that each button will ultimately perform on the media player. The following actions are responsible for stopping, pausing, playing, and exiting the MP3 player application:

Stop - mediaPlayer.stop();

Pause - mediaPlayer.pause();
Play - mediaPlayer.play();
Close - Platform.exit();

Play Progress, Rewind, and Fast Forward

After creating the custom button panel in our start() method, the code invokes the private method createSlider(), which returns a JavaFX Slider UI control. The slider control represents a progress position slider that moves based on the position of the media as it is being played. When the media is paused, the user may also drag the slider control’s “thumb” to seek backward and forward in time through the audio or video media.

Seeking Backward or Forward in the Media

The following code implements a slider control representing a progress (seek) position slider control for the MP3 audio player application:

Slider slider = new Slider(0, 100, 1);
slider.setId(SEEK_POS_SLIDER_ID);
slider.valueProperty()
      .addListener((observable) -> {
          if (slider.isValueChanging()) {
            // must check if media is paused before seeking
            if (mediaPlayer != null
                && mediaPlayer.getStatus() == MediaPlayer.Status.PAUSED) {
               //convert seconds to millis
               double dur = slider.getValue() * 1000;
               mediaPlayer.seek(Duration.millis(dur));
             }
          }
      }); //addListener()

Notice the slider control’s valueProperty() method and the call to the addListener() method, which has an InvalidationListener (lambda expression) that detects whether the user has moved the slider control, thus changing the value property. The lambda parameter for an InvalidationListener (functional interface) is an object of type javafx.beans.Observable that refers to the slider’s value property.

The code first checks whether the slider’s value has changed, by using the method isValueChanging(). It’s important to perform this check before the seek() method because of performance efficiencies. Using the InvalidationListener, the slider’s value changes only when it’s needed (an example of lazy evaluation). If you used a ChangeListener (eager evaluation), the value would change too often and could overwhelm the media player.

Using a Slider Control to Show Play Progress

Still in our start() method, the code assigns the variable progressListener to a ChangeListener (lambda) instance as shown in the following code snippet:

// update slider as media is progressing
progressListener = (observable, oldValue, newValue) ->
   progressSlider.setValue(newValue.toSeconds());

The progressListener is responsible for updating the slider’s “thumb” as the media player is playing audio or video media. The listener is added onto the media player object’s currentTimeProperty() in the playMedia() method. In this private method, the code cleans up by removing the singleton progressListener instance, which was bound to an old media player object, and adds the progressListener onto a newly created media player object. By removing old references to the progress listener, we allow the Java runtime to perform its garbage collection process, thus preventing memory leaks.

Setting Up Drag-and-Drop Support for Audio Files

Continuing within our start() method, notice the call to a private method initFileDragNDrop(). I created this method to support the drag-and-drop capability that is responsible for loading an audio file from the local file system or browser URL address field. Because the implementation is nearly identical to Chapter 5’s photo viewer example, I will not discuss the code details, except to mention the invocation of the private method playMedia(). After the file is dropped the URL string is passed into the method playMedia(). This method will conveniently clean up and create a new media player and immediately play the media.

Cleaning Up

Inside the initFileDragNDrop() method is the OnDragDropped event handler code, which invokes the private playMedia() method. This method begins by checking for the existence of a previously created mediaPlayer object. If there was a previous media player instance, the code would stop the media player and clean up any old references to events, listeners, and properties.

Media Metadata

Next, the code creates a Media class instance with a valid URL string. After creating a Media instance, the code loops through the media’s metadata via the getMetadata() method to display the keys onto the console. Sometimes MP3 music files will contain information about the artist, tracks, or song titles. By interrogating the media object’s metadata, you can display the information to the user.

After listing out metadata, the code creates a new MediaPlayer instance with a new Media object and adds the progressListener onto the media player’s currentTimeProperty() method. As mentioned earlier for the progress position slider control, the “thumb” will progress forward as the media is being played.

Playing Media (the OnReady Event)

Continuing within the playMedia() method, the code creates a Runnable (lambda expression) instance to respond to the OnReady event on the media player object. The code first resets the Play and Pause buttons of the custom button control panel by invoking the updatePlayAndPauseButtons() method. Passing in true displays the Play button and hides the Pause button; otherwise the Pause button is shown and the Play button is hidden.

Next, the code initializes the slider control based on the media’s total duration. Finally, the code invokes the media player object’s play() method to begin playing the music media.

Rewinding (the OnEndOfMedia Event)

After setting up the media player’s OnReady event, the code sets up the OnEndOfMedia event. This event is triggered when the media player play position has reached the end. The code toggles the Play and Pause buttons’ appearance and rewinds the media to the start, placing it in a Ready state.

Updating the Visualization Using the AudioSpectrumListener Interface

Do you remember the earlier discussion about the MP3 audio player’s visualization of randomly colored circles dancing about as the music plays? Still within the playMedia() method, let’s look at how to implement this mesmerizing effect.

Continuing with our playMedia() method, the code will obtain the container node viz-container that was created earlier. The following lines retrieve the container node using the scene’s lookup() method:

// setup visualization (circle container)
Group vizContainer = (Group) PRIMARY_STAGE.getScene()
                                          .lookup("#" + VIS_CONTAINER_ID);

Next, the code sets the media player’s audio spectrum listener with an AudioSpectrumListener (lambda expression) instance. The listener is notified periodically with updates of the audio spectrum. The following code updates the container node (vizContainer) with randomly colored Circle shapes based on the phases array:

mediaPlayer.setAudioSpectrumListener(
   (double timestamp,
    double duration,
    float[] magnitudes,
    float[] phases) -> {
      vizContainer.getChildren().clear();
      int i = 0;
      int x = 10;
      double y = PRIMARY_STAGE.getScene().getHeight() / 2;
      Random rand = new Random(System.currentTimeMillis());
      // Build random colored circles
      for (float phase : phases) {
         int red = rand.nextInt(255);
         int green = rand.nextInt(255);
         int blue = rand.nextInt(255);
         Circle circle = new Circle(10);
         circle.setCenterX(x + i);
         circle.setCenterY(y + (phase * 100));
         circle.setFill(Color.rgb(red, green, blue, .70));
         vizContainer.getChildren().add(circle);
         i += 5;
      }
}); // setAudioSpectrumListener()

This code creates randomly colored circle nodes to be positioned and placed on the scene based on the phases array, an array of float values. To draw each colored circle, the code increments the Circle’s center X by 5 pixels and adds the circle’s center Y with each phase value multiplied by 100. Shown next is the code that plots each randomly colored circle:

circle.setCenterX(x + i);
circle.setCenterY(y + (phase * 100));
... // setting the circle
i+=5;

What exactly is this type of listener? According to the Javadocs, it is an observer receiving periodic updates of the audio spectrum. In layman’s terms, it is the audio media’s sound data such as volume, tempo, and so on. To create an instance of an AudioSpectrumListener you will create a lambda expression with parameters of the interface’s method spectrumDataUpdate(). Table 7-3 lists all the inbound parameters for the audio spectrum listener’s method. For more details refer to the Javadocs documentation for javafx.scene.media.AudioSpectrumListener.

Table 7-3. The AudioSpectrumListener’s Method spectrumDataUpdate() Inbound Parameters

image

Quitting (the Close Button)

The rest of the start() method in our MP3 audio player’s code creates a Close button by calling the private method createCloseButton(), which returns a custom node that has handler code to exit the application.

Playing Video

Playing videos in JavaFX is quite simple. You will use the same APIs as with audio to load media into a JavaFX media player object. The only difference is that you will need a MediaView node to display the video onto the scene. Because MediaView is a JavaFX node, you will be able to transform, scale, translate, and apply effects. In this section you will learn about cross-platform video media file formats, and later you will see an example of a JavaFX video player.

Note  As of the writing of this book, the JavaFX media player API supports the video formats MPEG-4 H.264/AVC (.mp4) and VP6 using an .flv container format.

MPEG-4

MPEG-4 is a multimedia container format containing H.264/AVC video. Currently MPEG-4 is a compressed video format that is widely used to view video on the Internet. You’ll find that some high-definition video recorders can automatically convert raw video to MPEG-4 to produce files with the extension .mp4. For the devices that do not produce mp4 files, you currently need to purchase movie or video editing software capable of converting raw videos into MP4-formatted files. The following software, among others, can convert raw video into the portable MPEG-4 format:

  • Adobe Premiere
  • Apple Final Cut Pro
  • Apples iMovie
  • Apple Quick Time Pro
  • Sony Vegas Movie Studio
  • Sony Vegas Pro
  • FFmpeg

VP6 .flv

JavaFX 2.x and later supports a cross-platform video format called VP6 with a file extension of .flv (which stands for the popular Adobe Flash Video format). The actual encoder and decoder (codec) to create VP6 and .flv files are licensed through a company called On2. In 2010, On2 was acquired by Google to build VP7 and VP8 to be open and free to advance HTML5.

Please refer to the Javadocs for more details on the formats to be used. A word to the wise: beware of web sites claiming to be able to convert videos for free. As of this writing, the only encoders capable of encoding video to VP6 legally are the commercial converters from Adobe and Wildform (http://www.wildform.com).

You are probably wondering how to obtain such a file of the type FLV when you don’t have encoding software. If you don’t have an .flv file lying around, you can obtain one from one of my favorite sites, called the Media College (http://www.mediacollege.com). From photography to movies, Media College provides forums, tutorials, and resources that help guide you into the world of media.

There you will obtain a particular media file to be used in one of the examples in this chapter relating to closed captioning. To obtain the .flv file, navigate to the following URL: http://www.mediacollege.com/adobe/flash/video/tutorial/example-flv.html.

Next, locate the link entitled Windy 50s Mobility Scooter Race, which points to our .flv media file (20051210-w50s.flv). In order to download a link to the desired file, right-click to select Save Target As or Save . Once you have saved the file locally on your file system, you can use it for examples in this chapter.

It is not required that you have FLV files for this chapter. And you may use MPEG-4 (mp4) formatted files for the examples.

A Video Player Example

Figure 7-5 shows an Mpeg-4 (.mp4) home video shot from my FlipCam. I simply dragged the mp4 file right onto the surface of the application, and the video began playing. In this section you will learn how to implement a basic JavaFX video player application.

9781430264606_Fig07-05.jpg

Figure 7-5. A basic JavaFX video player

To create a video player, all you need to do is reuse the previous MP3 audio player example and make very minor adjustments. Actually, the code is mostly identical to the MP3 audio player example, except that the visualization container node (Group) is replaced with a javafx.scene.media.MediaView node as shown in Figure 7-5.

Because the core functionality hasn’t changed, you will experience the same features such as seeking, stopping, pausing, and playing media. Another bonus feature added to the video player is the ability to put the video into full-screen mode by double-clicking the application window area. To restore the stage window, repeat the double-click or press the Esc key.

Note  Because all video player examples in this chapter were built from the MP3 audio player project, you will notice that features such as seeking, stopping, pausing, and playing media are identical. For the sake of brevity in the remaining examples I will list only the relevant code parts, demonstrating new features, and omit sections that existed in the core code from the MP3 Audio Player example.

Video Player Source Code

Listing 7-3 is the implementation of a JavaFX video player application. After you have examined the code, I will discuss how it works.

Listing 7-3. Source code for a JavaFX Video Player Application

/**
 * Chapter 7: Playing Video
 *
 * @author cdea
 */
public class PlayingVideo extends Application {
    // ... the rest of the instance variables
    private static final String MEDIA_VIEW_ID = "media-view";
 
    // ... The rest of the static variables
 
    @Override
    public void start(final Stage primaryStage) {
        // ... Code omitted
 
        // Initialize stage to have fullscreen ability
        initFullScreenMode();
 
        // Create a media view to display video
        MediaView mediaView = createMediaView();
 
        // make media view as the second node on the scene.
        root.getChildren().add(1, mediaView);
 
     // ... Code omitted
    }
 
    /**
     * Attaches event handler code (mouse event) to
     * toggle to Full screen mode. Double click the scene.
     */
    private void initFullScreenMode() {
       Scene scene = PRIMARY_STAGE.getScene();
       // Full screen toggle
       scene.setOnMouseClicked((MouseEvent event) -> {
          if (event.getClickCount() == 2) {
             PRIMARY_STAGE.setFullScreen(!PRIMARY_STAGE.isFullScreen());
          }
       });
    }
 
    /**
     * Initialize the stage to allow the mouse cursor to move the
     * application using dragging.
     * @param stage
     */
    private void initMovablePlayer() {
 
        // ... Code omitted
    }
 
    /**
     * Initialize the Drag and Drop ability for audio/video files.
     * @param stage Primary Stage
     */
    private void initFileDragNDrop() {
 
        // ... Code omitted
    }
 
    /**
     * After a file is dragged into the application a new
     * MediaPlayer is created with a media file.
     * @param stage The stage window (primaryStage)
     * @param url The url pointing to an audio file
     */
    private void playMedia(String url) {
 
        // ... Code omitted
        // create a new media player
        // ... Code omitted
 
        // set the media player to display video
        MediaView mediaView = (MediaView) scene.lookup("#" + MEDIA_VIEW_ID);
        mediaView.setMediaPlayer(mediaPlayer);
    } // playMedia()
 
   /**
    * Create a MediaView node.
    *
    * @return MediaView
    */
   private MediaView createMediaView() {
      MediaView mediaView = new MediaView();
      mediaView.setId(MEDIA_VIEW_ID);
 
      mediaView.setPreserveRatio(true);
      mediaView.setSmooth(true);
      mediaView.fitWidthProperty()
            .bind(PRIMARY_STAGE.getScene()
                  .widthProperty()
                  .subtract(220));
      mediaView.fitHeightProperty()
            .bind(PRIMARY_STAGE.getScene()
                  .heightProperty()
                  .subtract(30));
      // sometimes loading errors occur
      mediaView.setOnError(mediaErrorEvent -> {
         mediaErrorEvent.getMediaError()
               .printStackTrace();
      });
 
      return mediaView;
   }
 
    /**
     * Creates a node containing the audio player's stop,
     * pause and play buttons.
     *
     * @param primaryStage
     * @return Node A button panel having play,
     * pause and stop buttons.
     */
    private Node createButtonPanel() {
        // ... Code omitted
    }
 
    /**
     * A simple rectangular area as the surface of the app.
     * @param stage the primary stage window.
     * @return Node a Rectangle node.
     */
    private Node createApplicationArea() {
        // ... Code omitted
    }
 
    /**
     * A slider to seek backward and forward that is bound to
     * a media player control.
     * @return Slider control bound to media player.
     */
    private Slider createSlider() {
        // ... Code omitted
    }
 
    /** The close button to exit application
     * @return Node representing a close button.
     */
    private Node createCloseButton() {
        // ... Code omitted
    }
    /**
     * Sets play button visible and pause button not visible
     * when playVisible is true otherwise the opposite.
     * @param playVisible - value of true the play becomes
     * visible and pause non visible.
     */
    private void updatePlayAndPauseButtons(boolean playVisible) {
        // ... Code omitted
    }
}

How It Works

Let’s begin by discussing the instance and static variables defined in the class. Because this example is mostly the same as the MP3 audio player example, I will only discuss the newer variables. In Listing 7-3 the code creates a static final String MEDIA_VIEW_ID variable for the MediaView object’s id for later retrieval using the scene’s lookup() method.

Setting Up the Stage Window

Beginning with the start() method, the code has already set up the capability to drag the stage window, via the call to the initMovablePlayer() method (code omitted).

Next, the code invokes the initFullScreenMode() method, which attaches handler code to listen for a mouse double-click event. When a user double-clicks the background surface (scene), the application and media view will become full screen. The following code snippet is handler code set on the OnMouseClicked event.

// Full screen toggle
scene.setOnMouseClicked((MouseEvent event) -> {
   if (event.getClickCount() == 2) {
      PRIMARY_STAGE.setFullScreen(!PRIMARY_STAGE.isFullScreen());
   }
});

The MediaView Node

After adding the full-screen capability, the code invokes the private method createMediaView(), which returns a MediaView node that is responsible for displaying the video. The following code calls the createMediaView() to be added as the second node, which places the media view node on top of the application background (rounded rectangle):

// Create a media view to display video
MediaView mediaView = createMediaView();
 
// make media view as the second node on the scene.
root.getChildren().add(1, mediaView);

To see what the createMediaView() method actually returns, let’s look at how it was implemented. The method begins by instantiating a new MediaView class and setting the id with MEDIA_VIEW_ID (media-view). Next, it invokes the media view object’s setPreserveRatio() with a value of true. The setPreserveRatio() method will preserve the aspect ratio of the video when being resized. The resizing occurs when the application goes into full screen mode and back to normal size. The remaining code binds properties relating to the width and height relative to the size of the scene’s (stage window) dimensions.

Playing Video

Finally, consider the private method playMedia(), which is responsible for receiving a media file after a drag-and-drop. Here, is where the code takes the existing media player object to be associated with the MediaView node created earlier. The following code uses the scene’s lookup() method to retrieve the media view and reference the newly created media player instance:

// set the media player to display video
MediaView mediaView = (MediaView) scene.lookup("#" + MEDIA_VIEW_ID);
mediaView.setMediaPlayer(mediaPlayer);

Simulating Closed Captioning: Marking a Position in a Video

Have you ever wanted to implement closed-captioned video? In the past there have been discussions about JavaFX 8 possibly supporting the standard closed-captioning specification for video media; however, the state of this feature is unclear as of this writing. Fortunately, there is hope; JavaFX supports media event markers to achieve the same behavior as closed-captioned video.

Closed-Captioning Video Example

To demonstrate a closed-captioned video example, I created a NetBeans project called ClosedCaptionVideo. For the sake of space I will show here only the code parts that are relevant to media event markers (Listing 7-4). To see the full source code, please visit the book’s website to download the code for this chapter.

When we discussed video media file formats earlier, I noted that an FLV file could be found at the following URL: http://www.mediacollege.com/adobe/flash/video/tutorial/example-flv.html. Here, I used the file 20051210-w50s.flv for this closed caption video example. Figure 7-6 depicts closed captions (Text) being displayed at certain points in the video.

9781430264606_Fig07-06.jpg

Figure 7-6. Closed-captioned video

Listing 7-4. Media Marker Events

// obtain media
Media media = new Media(url);
 
// add media markers
media.getMarkers().put("Starting race", Duration.millis(1959));
media.getMarkers().put("He is begining to get ahead",
   Duration.millis(3395));
media.getMarkers().put("They are turning the corner",
   Duration.millis(6060));
media.getMarkers().put("The crowds cheer",
   Duration.millis(9064));
media.getMarkers().put("He makes the finish line",
   Duration.millis(11546));
 
MediaPlayer mediaPlayer = new MediaPlayer(media);
 
Text closedCaption = (Text) scene.lookup("#" + CLOSED_CAPTION_TEXT_ID);
mediaPlayer.setOnMarker((MediaMarkerEvent event) ->
    closedCaption.setText(event.getMarker().getKey())
); setOnMarker()

How It Works

Listing 7-4 shows a newly created Media object based on a valid URL string. Next, the code adds key/value pairs as markers in media. Each marker pair consists of a key of type String and a value of type Duration (in milliseconds). The duration is the marker in time into the media that will trigger an OnMarker event. The media can be video or audio.

You’ll immediately notice that the setOnMarker() method is set with a lambda expression with a parameter of type MediaMarkerEvent. The MediaMarkerEvent closure code is invoked when the OnMarker event is raised. The closure code simply sets the closedCaption (javafx.scene.text.Text) node with the key’s value as the closed-captioning text.

Summary

In this chapter you discovered the world of JavaFX media. You began by learning about media events and event-based programming when dealing with the JavaFX Media APIs. Next, you saw how easy it is to take a media file as a URL string to be loaded and played. After learning about the MediaPlayer API, you got a chance to look at an MP3 audio player example that demonstrated features such as seek, stop, pause and play. Also, you learned how to implement a cool visualization using the AudioSpectrumListener interface.

After you learned about audio media, we discussed video. With video media you were able to learn about currently supported media formats such as Mpeg-4 and FLV. Then you saw an example of a basic JavaFX video player application. Here, you discovered that playing video is the same as playing audio, except for the use of the MediaView node, which is capable of displaying the video. Finally, you were exposed to a short example of media marker events (MediaMarkerEvent) to simulate a closed-captioned video.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset