1 /*
   2  * Copyright (c) 2010, 2015, Oracle and/or its affiliates. All rights reserved.
   3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
   4  *
   5  * This code is free software; you can redistribute it and/or modify it
   6  * under the terms of the GNU General Public License version 2 only, as
   7  * published by the Free Software Foundation.  Oracle designates this
   8  * particular file as subject to the "Classpath" exception as provided
   9  * by Oracle in the LICENSE file that accompanied this code.
  10  *
  11  * This code is distributed in the hope that it will be useful, but WITHOUT
  12  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  13  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
  14  * version 2 for more details (a copy is included in the LICENSE file that
  15  * accompanied this code).
  16  *
  17  * You should have received a copy of the GNU General Public License version
  18  * 2 along with this work; if not, write to the Free Software Foundation,
  19  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
  20  *
  21  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
  22  * or visit www.oracle.com if you need additional information or have any
  23  * questions.
  24  */
  25 
  26 package javafx.scene.media;
  27 
  28 import com.sun.media.jfxmedia.MetadataParser;
  29 import java.io.ByteArrayInputStream;
  30 import java.io.IOException;
  31 import java.io.FileNotFoundException;
  32 import java.net.URI;
  33 import java.net.URISyntaxException;
  34 import java.util.HashMap;
  35 import java.util.List;
  36 import java.util.Map;
  37 
  38 import javafx.application.Platform;
  39 import javafx.beans.NamedArg;
  40 import javafx.beans.property.ObjectProperty;
  41 import javafx.beans.property.ObjectPropertyBase;
  42 import javafx.collections.FXCollections;
  43 import javafx.collections.ObservableList;
  44 import javafx.collections.ObservableMap;
  45 import javafx.scene.image.Image;
  46 import javafx.util.Duration;
  47 
  48 import com.sun.media.jfxmedia.locator.Locator;
  49 import javafx.beans.property.ReadOnlyIntegerProperty;
  50 import javafx.beans.property.ReadOnlyIntegerWrapper;
  51 import javafx.beans.property.ReadOnlyObjectProperty;
  52 import javafx.beans.property.ReadOnlyObjectWrapper;
  53 import com.sun.media.jfxmedia.events.MetadataListener;
  54 import com.sun.media.jfxmedia.track.VideoResolution;
  55 
  56 /**
  57  * The <code>Media</code> class represents a media resource. It is instantiated
  58  * from the string form of a source URI. Information about the media such as
  59  * duration, metadata, tracks, and video resolution may be obtained from a
  60  * <code>Media</code> instance. The media information is obtained asynchronously
  61  * and so not necessarily available immediately after instantiation of the class.
  62  * All information should however be available if the instance has been
  63  * associated with a {@link MediaPlayer} and that player has transitioned to
  64  * {@link MediaPlayer.Status#READY} status. To be notified when metadata or
  65  * {@link Track}s are added, observers may be registered with the collections
  66  * returned by {@link #getMetadata()}and {@link #getTracks()}, respectively.</p>
  67  *
  68  * <p>The same <code>Media</code> object may be shared among multiple
  69  * <code>MediaPlayer</code> objects. Such a shared instance might manage a single
  70  * copy of the source media data to be used by all players, or it might require a
  71  * separate copy of the data for each player. The choice of implementation will
  72  * not however have any effect on player behavior at the interface level.</p>
  73  *
  74  * @see MediaPlayer
  75  * @see MediaException
  76  * @since JavaFX 2.0
  77  */
  78 public final class Media {
  79     /**
  80      * A property set to a MediaException value when an error occurs.
  81      * If <code>error</code> is non-<code>null</code>, then the media could not
  82      * be loaded and is not usable. If {@link #onErrorProperty onError} is non-<code>null</code>,
  83      * it will be invoked when the <code>error</code> property is set.
  84      *
  85      * @see MediaException
  86      */
  87     private ReadOnlyObjectWrapper<MediaException> error;
  88 
  89     private void setError(MediaException value) {
  90         errorPropertyImpl().set(value);
  91     }
  92 
  93     /**
  94      * Return any error encountered in the media.
  95      * @return a {@link MediaException} or <code>null</code> if there is no error.
  96      */
  97     public final MediaException getError() {
  98         return error == null ? null : error.get();
  99     }
 100 
 101     public ReadOnlyObjectProperty<MediaException> errorProperty() {
 102         return errorPropertyImpl().getReadOnlyProperty();
 103     }
 104 
 105     private ReadOnlyObjectWrapper<MediaException> errorPropertyImpl() {
 106         if (error == null) {
 107             error = new ReadOnlyObjectWrapper<MediaException>() {
 108 
 109                 @Override
 110                 protected void invalidated() {
 111                     if (getOnError() != null) {
 112                         Platform.runLater(getOnError());
 113                     }
 114                 }
 115 
 116                 @Override
 117                 public Object getBean() {
 118                     return Media.this;
 119                 }
 120 
 121                 @Override
 122                 public String getName() {
 123                     return "error";
 124                 }
 125             };
 126         }
 127         return error;
 128     }
 129     /**
 130      * Event handler called when an error occurs. This will happen
 131      * if a malformed or invalid URL is passed to the constructor or there is
 132      * a problem accessing the URL.
 133      */
 134     private ObjectProperty<Runnable> onError;
 135 
 136     /**
 137      * Set the event handler to be called when an error occurs.
 138      * @param value the error event handler.
 139      */
 140     public final void setOnError(Runnable value) {
 141         onErrorProperty().set(value);
 142     }
 143 
 144     /**
 145      * Retrieve the error handler to be called if an error occurs.
 146      * @return the error handler or <code>null</code> if none is defined.
 147      */
 148     public final Runnable getOnError() {
 149         return onError == null ? null : onError.get();
 150     }
 151 
 152     public ObjectProperty<Runnable> onErrorProperty() {
 153         if (onError == null) {
 154             onError = new ObjectPropertyBase<Runnable>() {
 155 
 156                 @Override
 157                 protected void invalidated() {
 158                     /*
 159                      * if we have an existing error condition schedule the handler to be
 160                      * called immediately. This way the client app does not have to perform
 161                      * an explicit error check.
 162                      */
 163                     if (get() != null && getError() != null) {
 164                         Platform.runLater(get());
 165                     }
 166                 }
 167 
 168                 @Override
 169                 public Object getBean() {
 170                     return Media.this;
 171                 }
 172 
 173                 @Override
 174                 public String getName() {
 175                     return "onError";
 176                 }
 177             };
 178         }
 179         return onError;
 180     }
 181 
 182     private MetadataListener metadataListener = new _MetadataListener();
 183 
 184     /**
 185      * An {@link ObservableMap} of metadata which can contain information about
 186      * the media. Metadata entries use {@link String}s for keys and contain
 187      * {@link Object} values. This map is unmodifiable: its contents or stored
 188      * values cannot be changed.
 189      */
 190     // FIXME: define standard metadata keys and the corresponding objects types
 191     // FIXME: figure out how to make the entries read-only to observers, we'll
 192     //        need to enhance javafx.collections a bit to accomodate this
 193     private ObservableMap<String, Object> metadata;
 194 
 195     /**
 196      * Retrieve the metadata contained in this media source. If there are
 197      * no metadata, the returned {@link ObservableMap} will be empty.
 198      * @return the metadata contained in this media source.
 199      */
 200     public final ObservableMap<String, Object> getMetadata() {
 201         return metadata;
 202     }
 203 
 204     private final ObservableMap<String,Object> metadataBacking = FXCollections.observableMap(new HashMap<String,Object>());
 205     /**
 206      * The width in pixels of the source media.
 207      * This may be zero if the media has no width, e.g., when playing audio,
 208      * or if the width is currently unknown which may occur with streaming
 209      * media.
 210      * @see height
 211      */
 212     private ReadOnlyIntegerWrapper width;
 213 
 214 
 215     final void setWidth(int value) {
 216         widthPropertyImpl().set(value);
 217     }
 218 
 219     /**
 220      * Retrieve the width in pixels of the media.
 221      * @return the media width or zero if the width is undefined or unknown.
 222      */
 223     public final int getWidth() {
 224         return width == null ? 0 : width.get();
 225     }
 226 
 227     public ReadOnlyIntegerProperty widthProperty() {
 228         return widthPropertyImpl().getReadOnlyProperty();
 229     }
 230 
 231     private ReadOnlyIntegerWrapper widthPropertyImpl() {
 232         if (width == null) {
 233             width = new ReadOnlyIntegerWrapper(this, "width");
 234         }
 235         return width;
 236     }
 237     /**
 238      * The height in pixels of the source media.
 239      * This may be zero if the media has no height, e.g., when playing audio,
 240      * or if the height is currently unknown which may occur with streaming
 241      * media.
 242      * @see width
 243      */
 244     private ReadOnlyIntegerWrapper height;
 245 
 246 
 247     final void setHeight(int value) {
 248         heightPropertyImpl().set(value);
 249     }
 250 
 251     /**
 252      * Retrieve the height in pixels of the media.
 253      * @return the media height or zero if the height is undefined or unknown.
 254      */
 255     public final int getHeight() {
 256         return height == null ? 0 : height.get();
 257     }
 258 
 259     public ReadOnlyIntegerProperty heightProperty() {
 260         return heightPropertyImpl().getReadOnlyProperty();
 261     }
 262 
 263     private ReadOnlyIntegerWrapper heightPropertyImpl() {
 264         if (height == null) {
 265             height = new ReadOnlyIntegerWrapper(this, "height");
 266         }
 267         return height;
 268     }
 269     /**
 270      * The duration in seconds of the source media. If the media duration is
 271      * unknown then this property value will be {@link Duration#UNKNOWN}.
 272      */
 273     private ReadOnlyObjectWrapper<Duration> duration;
 274 
 275     final void setDuration(Duration value) {
 276         durationPropertyImpl().set(value);
 277     }
 278 
 279     /**
 280      * Retrieve the duration in seconds of the media.
 281      * @return the duration of the media, {@link Duration#UNKNOWN} if unknown or {@link Duration#INDEFINITE} for live streams
 282      */
 283     public final Duration getDuration() {
 284         return duration == null || duration.get() == null ? Duration.UNKNOWN : duration.get();
 285     }
 286 
 287     public ReadOnlyObjectProperty<Duration> durationProperty() {
 288         return durationPropertyImpl().getReadOnlyProperty();
 289     }
 290 
 291     private ReadOnlyObjectWrapper<Duration> durationPropertyImpl() {
 292         if (duration == null) {
 293             duration = new ReadOnlyObjectWrapper<Duration>(this, "duration");
 294         }
 295         return duration;
 296     }
 297     /**
 298      * An <code>ObservableList</code> of tracks contained in this media object.
 299      * A <code>Media</code> object can contain multiple tracks, such as a video track
 300      * with several audio track. This list is unmodifiable: the contents cannot
 301      * be changed.
 302      * @see Track
 303      */
 304     private ObservableList<Track> tracks;
 305 
 306     /**
 307      * Retrieve the tracks contained in this media source. If there are
 308      * no tracks, the returned {@link ObservableList} will be empty.
 309      * @return the tracks contained in this media source.
 310      */
 311     public final ObservableList<Track> getTracks() {
 312         return tracks;
 313     }
 314     private final ObservableList<Track> tracksBacking = FXCollections.observableArrayList();
 315 
 316     /**
 317      * The markers defined on this media source. A marker is defined to be a
 318      * mapping from a name to a point in time between the beginning and end of
 319      * the media.
 320      */
 321     private ObservableMap<String, Duration> markers = FXCollections.observableMap(new HashMap<String,Duration>());
 322 
 323     /**
 324      * Retrieve the markers defined on this <code>Media</code> instance. If
 325      * there are no markers the returned {@link ObservableMap} will be empty.
 326      * Programmatic markers may be added by inserting entries in the returned
 327      * <code>Map</code>.
 328      *
 329      * @return the markers defined on this media source.
 330      */
 331     public final ObservableMap<String, Duration> getMarkers() {
 332         return markers;
 333     }
 334 
 335     /**
 336      * Constructs a <code>Media</code> instance.  This is the only way to
 337      * specify the media source. The source must represent a valid <code>URI</code>
 338      * and is immutable. Only HTTP, FILE, and JAR <code>URL</code>s are supported. If the
 339      * provided URL is invalid then an exception will be thrown.  If an
 340      * asynchronous error occurs, the {@link #errorProperty error} property will be set. Listen
 341      * to this property to be notified of any such errors.
 342      *
 343      * <p>If the source uses a non-blocking protocol such as FILE, then any
 344      * problems which can be detected immediately will cause a <code>MediaException</code>
 345      * to be thrown. Such problems include the media being inaccessible or in an
 346      * unsupported format. If however a potentially blocking protocol such as
 347      * HTTP is used, then the connection will be initialized asynchronously so
 348      * that these sorts of errors will be signaled by setting the {@link #errorProperty error}
 349      * property.</p>
 350      *
 351      * <p>Constraints:
 352      * <ul>
 353      * <li>The supplied URI must conform to RFC-2396 as required by
 354      * <A href="https://docs.oracle.com/javase/8/docs/api/java/net/URI.html">java.net.URI</A>.</li>
 355      * <li>Only HTTP, FILE, and JAR URIs are supported.</li>
 356      * </ul>
 357      *
 358      * <p>See <A href="https://docs.oracle.com/javase/8/docs/api/java/net/URI.html">java.net.URI</A>
 359      * for more information about URI formatting in general.
 360      * JAR URL syntax is specified in <a href="https://docs.oracle.com/javase/8/docs/api/java/net/JarURLConnection.html">java.net.JarURLConnection</A>.
 361      *
 362      * @param source The URI of the source media.
 363      * @throws NullPointerException if the URI string is <code>null</code>.
 364      * @throws IllegalArgumentException if the URI string does not conform to RFC-2396
 365      * or, if appropriate, the Jar URL specification, or is in a non-compliant
 366      * form which cannot be modified to a compliant form.
 367      * @throws IllegalArgumentException if the URI string has a <code>null</code>
 368      * scheme.
 369      * @throws UnsupportedOperationException if the protocol specified for the
 370      * source is not supported.
 371      * @throws MediaException if the media source cannot be connected
 372      * (type {@link MediaException.Type#MEDIA_INACCESSIBLE}) or is not supported
 373      * (type {@link MediaException.Type#MEDIA_UNSUPPORTED}).
 374      */
 375     public Media(@NamedArg("source") String source) {
 376         this.source = source;
 377 
 378         URI uri = null;
 379         try {
 380             // URI will throw NPE if source == null: do not catch it!
 381             uri = new URI(source);
 382         } catch(URISyntaxException use) {
 383             throw new IllegalArgumentException(use);
 384         }
 385 
 386         metadata = FXCollections.unmodifiableObservableMap(metadataBacking);
 387         tracks = FXCollections.unmodifiableObservableList(tracksBacking);
 388 
 389         Locator locator = null;
 390         try {
 391             locator = new com.sun.media.jfxmedia.locator.Locator(uri);
 392             jfxLocator = locator;
 393             if (locator.canBlock()) {
 394                 InitLocator locatorInit = new InitLocator();
 395                 Thread t = new Thread(locatorInit);
 396                 t.setDaemon(true);
 397                 t.start();
 398             } else {
 399                 locator.init();
 400                 runMetadataParser();
 401             }
 402         } catch(URISyntaxException use) {
 403             throw new IllegalArgumentException(use);
 404         } catch(FileNotFoundException fnfe) {
 405             throw new MediaException(MediaException.Type.MEDIA_UNAVAILABLE, fnfe.getMessage());
 406         } catch(IOException ioe) {
 407             throw new MediaException(MediaException.Type.MEDIA_INACCESSIBLE, ioe.getMessage());
 408         } catch(com.sun.media.jfxmedia.MediaException me) {
 409             throw new MediaException(MediaException.Type.MEDIA_UNSUPPORTED, me.getMessage());
 410         }
 411     }
 412 
 413     private void runMetadataParser() {
 414         try {
 415             jfxParser = com.sun.media.jfxmedia.MediaManager.getMetadataParser(jfxLocator);
 416             jfxParser.addListener(metadataListener);
 417             jfxParser.startParser();
 418         } catch (Exception e) {
 419             jfxParser = null;
 420         }
 421     }
 422 
 423     /**
 424      * The source URI of the media;
 425      */
 426     private final String source;
 427 
 428     /**
 429      * Retrieve the source URI of the media.
 430      * @return the media source URI as a {@link String}.
 431      */
 432     public String getSource() {
 433         return source;
 434     }
 435 
 436     /**
 437      * Locator used by the jfxmedia player, MediaPlayer needs access to this
 438      */
 439     private final Locator jfxLocator;
 440     Locator retrieveJfxLocator() {
 441         return jfxLocator;
 442     }
 443 
 444     private MetadataParser jfxParser;
 445 
 446     private Track getTrackWithID(long trackID) {
 447         for (Track track : tracksBacking) {
 448             if (track.getTrackID() == trackID) {
 449                 return track;
 450             }
 451         }
 452         return null;
 453     }
 454     
 455     // http://javafx-jira.kenai.com/browse/RT-24594
 456     // TODO: Remove this entire method (and associated stuff) when we switch to track parsing in MetadataParser
 457     void _updateMedia(com.sun.media.jfxmedia.Media _media) {
 458         try {
 459             List<com.sun.media.jfxmedia.track.Track> trackList = _media.getTracks();
 460 
 461             if (trackList != null) {
 462                 for (com.sun.media.jfxmedia.track.Track trackElement : trackList) {
 463                     long trackID = trackElement.getTrackID();
 464                     if (getTrackWithID(trackID) == null) {
 465                         Track newTrack = null;
 466                         Map<String,Object> trackMetadata = new HashMap<String,Object>();
 467                         if (null != trackElement.getName()) {
 468                             // FIXME: need constants for metadata keys (globally)
 469                             trackMetadata.put("name", trackElement.getName());
 470                         }
 471                         if (null != trackElement.getLocale()) {
 472                             trackMetadata.put("locale", trackElement.getLocale());
 473                         }
 474                         trackMetadata.put("encoding", trackElement.getEncodingType().toString());
 475                         trackMetadata.put("enabled", Boolean.valueOf(trackElement.isEnabled()));
 476                         
 477                         if (trackElement instanceof com.sun.media.jfxmedia.track.VideoTrack) {
 478                             com.sun.media.jfxmedia.track.VideoTrack vt =
 479                                     (com.sun.media.jfxmedia.track.VideoTrack) trackElement;
 480 
 481                             int videoWidth = vt.getFrameSize().getWidth();
 482                             int videoHeight = vt.getFrameSize().getHeight();
 483                             
 484                             // FIXME: this isn't valid when there are multiple video tracks...
 485                             setWidth(videoWidth);
 486                             setHeight(videoHeight);
 487 
 488                             trackMetadata.put("video width", Integer.valueOf(videoWidth));
 489                             trackMetadata.put("video height", Integer.valueOf(videoHeight));
 490                             
 491                             newTrack = new VideoTrack(trackElement.getTrackID(), trackMetadata);
 492                         } else if (trackElement instanceof com.sun.media.jfxmedia.track.AudioTrack) {
 493                             newTrack = new AudioTrack(trackElement.getTrackID(), trackMetadata);
 494                         } else if (trackElement instanceof com.sun.media.jfxmedia.track.SubtitleTrack) {
 495                             newTrack = new SubtitleTrack(trackID, trackMetadata);
 496                         }
 497                         
 498                         if (null != newTrack) {
 499                             tracksBacking.add(newTrack);
 500                         }
 501                     }
 502                 }
 503             }
 504         } catch (Exception e) {
 505             // Save any async exceptions as an error.
 506             setError(new MediaException(MediaException.Type.UNKNOWN, e));
 507         }
 508     }
 509 
 510     void _setError(MediaException.Type type, String message) {
 511         setError(new MediaException(type, message));
 512     }
 513 
 514     private synchronized void updateMetadata(Map<String, Object> metadata) {
 515         if (metadata != null) {
 516             for (Map.Entry<String,Object> entry : metadata.entrySet()) {
 517                 String key = entry.getKey();
 518                 Object value = entry.getValue();
 519                 if (key.equals(MetadataParser.IMAGE_TAG_NAME) && value instanceof byte[]) {
 520                     byte[] imageData = (byte[]) value;
 521                     Image image = new Image(new ByteArrayInputStream(imageData));
 522                     if (!image.isError()) {
 523                         metadataBacking.put(MetadataParser.IMAGE_TAG_NAME, image);
 524                     }
 525                 } else if (key.equals(MetadataParser.DURATION_TAG_NAME) && value instanceof java.lang.Long) {
 526                     Duration d = new Duration((Long) value);
 527                     if (d != null) {
 528                         metadataBacking.put(MetadataParser.DURATION_TAG_NAME, d);
 529                     }
 530                 } else {
 531                     metadataBacking.put(key, value);
 532                 }
 533             }
 534         }
 535     }
 536 
 537     private class _MetadataListener implements MetadataListener {
 538         @Override
 539         public void onMetadata(final Map<String, Object> metadata) {
 540             // Clean up metadata
 541             Platform.runLater(() -> {
 542                 updateMetadata(metadata);
 543                 jfxParser.removeListener(metadataListener);
 544                 jfxParser.stopParser();
 545                 jfxParser = null;
 546             });
 547         }
 548     }
 549 
 550     private class InitLocator implements Runnable {
 551 
 552         @Override
 553         public void run() {
 554             try {
 555                 jfxLocator.init();
 556                 runMetadataParser();
 557             } catch (URISyntaxException use) {
 558                 _setError(MediaException.Type.OPERATION_UNSUPPORTED, use.getMessage());
 559             } catch (FileNotFoundException fnfe) {
 560                 _setError(MediaException.Type.MEDIA_UNAVAILABLE, fnfe.getMessage());
 561             } catch (IOException ioe) {
 562                 _setError(MediaException.Type.MEDIA_INACCESSIBLE, ioe.getMessage());
 563             } catch (com.sun.media.jfxmedia.MediaException me) {
 564                 _setError(MediaException.Type.MEDIA_UNSUPPORTED, me.getMessage());
 565             } catch (Exception e) {
 566                 _setError(MediaException.Type.UNKNOWN, e.getMessage());
 567             }
 568         }
 569     }
 570 }