Merge branch 'Jalview-JS/develop' into merge_js_develop
[jalview.git] / src / swingjs / api / js / HTML5Video.java
diff --git a/src/swingjs/api/js/HTML5Video.java b/src/swingjs/api/js/HTML5Video.java
new file mode 100644 (file)
index 0000000..073f1ed
--- /dev/null
@@ -0,0 +1,472 @@
+package swingjs.api.js;
+
+import java.awt.Container;
+import java.awt.Dimension;
+import java.awt.Frame;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.net.URL;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.function.Function;
+
+import javax.swing.BoxLayout;
+import javax.swing.ImageIcon;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import swingjs.api.JSUtilI;
+
+/**
+ * A full-service interface for HTML5 video element interaction. Allows setting
+ * and getting HTML5 video element properties. ActionListeners can be set to
+ * listen for JavaScript events associated with a video element.
+ * 
+ * Video is added using a JavaScript-only two-parameter constructor for
+ * ImageIcon with "jsvideo" as the description, allowing for video construction
+ * from byte[], File, or URL.
+ * 
+ * After adding the ImageIcon to a JLabel, calling
+ * jlabel.getClientProperty("jsvideo") returns an HTML5 object of type
+ * HTML5Video (the <video> tag), which has the full suite of HTML5 video
+ * element properties, methods, and events.
+ * 
+ * Access to event listeners is via the method addActionListener, below, which
+ * return an ActionEvent that has as its source both the video element source as
+ * well as the original JavaScript event as an Object[] { jsvideo, event }. The
+ * id of this ActionEvent is 12345, and its command is the name of the event,
+ * for example, "canplay" or "canplaythrough".
+ * 
+ * See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement for
+ * details.
+ * 
+ * @author hansonr
+ *
+ */
+public interface HTML5Video extends DOMNode {
+
+       public interface Promise {
+
+       }
+
+       final static String[] eventTypes = new String[] { "audioprocess", // The input buffer of a ScriptProcessorNode is
+                                                                                                                                               // ready to be processed.
+                       "canplay", // The browser can play the media, but estimates that not enough data has been
+                                               // loaded to play the media up to its end without having to stop for further
+                                               // buffering of content.
+                       "canplaythrough", // The browser estimates it can play the media up to its end without stopping
+                                                               // for content buffering.
+                       "complete", // The rendering of an OfflineAudioContext is terminated.
+                       "durationchange", // The duration attribute has been updated.
+                       "emptied", // The media has become empty; for example, this event is sent if the media has
+                                               // already been loaded (or partially loaded), and the load() method is called to
+                                               // reload it.
+                       "ended", // Playback has stopped because the end of the media was reached.
+                       "loadeddata", // The first frame of the media has finished loading.
+                       "loadedmetadata", // The metadata has been loaded.
+                       "pause", // Playback has been paused.
+                       "play", // Playback has begun.
+                       "playing", // Playback is ready to start after having been paused or delayed due to lack of
+                                               // data.
+                       "progress", // Fired periodically as the browser loads a resource.
+                       "ratechange", // The playback rate has changed.
+                       "seeked", // A seek operation completed.
+                       "seeking", // A seek operation began.
+                       "stalled", // The user agent is trying to fetch media data, but data is unexpectedly not
+                                               // forthcoming.
+                       "suspend", // Media data loading has been suspended.
+                       "timeupdate", // The time indicated by the currentTimeattribute has been updated.
+                       "volumechange", // The volume has changed.
+                       "waiting", // Playback has stopped because of a temporary lack of data
+       };
+
+       // direct methods
+
+       public void addTextTrack() throws Throwable;
+
+       public Object captureStream() throws Throwable;
+
+       public String canPlayType(String mediaType) throws Throwable;
+
+       public void fastSeek(double time) throws Throwable;
+
+       public void load() throws Throwable;
+
+       public void mozCaptureStream() throws Throwable;
+
+       public void mozCaptureStreamUntilEnded() throws Throwable;
+
+       public void mozGetMetadata() throws Throwable;
+
+       public void pause() throws Throwable;
+
+       public Promise play() throws Throwable;
+
+       public Promise seekToNextFrame() throws Throwable;
+
+       public Promise setMediaKeys(Object mediaKeys) throws Throwable;
+
+       public Promise setSinkId(String id) throws Throwable;
+
+       // convenience methods
+
+       public static double getDuration(HTML5Video v) {
+               return /** @j2sNative v.duration || */
+               0;
+       }
+
+       public static double setCurrentTime(HTML5Video v, double time) {
+               return /** @j2sNative v.currentTime = time|| */
+               0;
+       }
+
+       public static double getCurrentTime(HTML5Video v) {
+               return /** @j2sNative v.currentTime|| */
+               0;
+       }
+
+       public static Dimension getSize(HTML5Video v) {
+               return new Dimension(/** @j2sNative v.videoWidth || */
+                               0, /** @j2sNative v.videoHeight|| */
+                               0);
+       }
+
+       /**
+        * 
+        * Create a BufferedIfmage from the current frame. The image will be of type
+        * swingjs.api.JSUtilI.TYPE_4BYTE_HTML5, matching the data buffer of HTML5
+        * images.
+        * 
+        * @param v
+        * @param imageType  if Integer.MIN_VALUE, swingjs.api.JSUtilI.TYPE_4BYTE_HTML5
+        * @return
+        */
+       public static BufferedImage getImage(HTML5Video v, int imageType) {
+               Dimension d = HTML5Video.getSize(v);
+               BufferedImage image = (BufferedImage) HTML5Video.getProperty(v, "_image");
+               if (image == null || image.getWidth() != d.width || image.getHeight() != d.height) {
+                       image = new BufferedImage(d.width, d.height, imageType == Integer.MIN_VALUE ? JSUtilI.TYPE_4BYTE_HTML5 : imageType);
+                       HTML5Video.setProperty(v, "_image", image);
+               }
+               HTML5Canvas.setImageNode(v, image);
+               return image;
+       }
+
+       // property setting and getting
+
+       /**
+        * Set a property of the the HTML5 video element using jsvideo[key] = value.
+        * Numbers and Booleans will be unboxed.
+        * 
+        * @param jsvideo the HTML5 video element
+        * @param key
+        * @param value
+        */
+       public static void setProperty(HTML5Video jsvideo, String key, Object value) {
+               if (value instanceof Number) {
+                       /** @j2sNative jsvideo[key] = +value; */
+               } else if (value instanceof Boolean) {
+                       /** @j2sNative jsvideo[key] = !!+value */
+               } else {
+                       /** @j2sNative jsvideo[key] = value; */
+               }
+       }
+
+       /**
+        * Get a property using jsvideo[key], boxing number as Double and boolean as
+        * Boolean.
+        * 
+        * @param jsvideo the HTML5 video element
+        * 
+        * @param key
+        * @return value or value boxed as Double or Boolean
+        */
+       @SuppressWarnings("unused")
+       public static Object getProperty(HTML5Video jsvideo, String key) {
+               Object val = (/** @j2sNative 1? jsvideo[key] : */
+               null);
+               if (val == null)
+                       return null;
+               switch (/** @j2sNative typeof val || */
+               "") {
+               case "number":
+                       return Double.valueOf(/** @j2sNative val || */
+                                       0);
+               case "boolean":
+                       return Boolean.valueOf(/** @j2sNative val || */
+                                       false);
+               default:
+                       return val;
+               }
+       }
+
+       // event action
+
+       /**
+        * Add an ActionListener for the designated events. When an event is fired,
+        * 
+        * @param jsvideo  the HTML5 video element
+        * @param listener
+        * @param events   array of events to listen to or null to listen on all video
+        *                 element event types
+        * @return an array of event/listener pairs that can be used for removal.
+        */
+       public static Object[] addActionListener(HTML5Video jsvideo, ActionListener listener, String... events) {
+               if (events == null || events.length == 0)
+                       events = eventTypes;
+               @SuppressWarnings("unused")
+               Function<Object, Void> f = new Function<Object, Void>() {
+
+                       @Override
+                       public Void apply(Object jsevent) {
+                               String name = (/** @j2sNative jsevent.type || */
+                               "?");
+                               System.out.println("HTML5Video " + name);
+                               ActionEvent e = new ActionEvent(new Object[] { jsvideo, jsevent }, 12345, name,
+                                               System.currentTimeMillis(), 0);
+                               listener.actionPerformed(e);
+                               return null;
+                       }
+               };
+               ArrayList<Object> listeners = new ArrayList<>();
+               for (int i = 0; i < events.length; i++) {
+                       Object func = /**
+                                                        * @j2sNative function(event){f.apply$O.apply(f, [event])} ||
+                                                        */
+                                       null;
+                       listeners.add(events[i]);
+                       listeners.add(func);
+                       if (jsvideo != null)
+                               jsvideo.addEventListener(events[i], func);
+
+               }
+               return listeners.toArray(new Object[listeners.size()]);
+       }
+
+       /**
+        * Remove action listener
+        * 
+        * @param jsvideo   the HTML5 video element
+        * @param listeners an array of event/listener pairs created by
+        *                  addActionListener
+        */
+       public static void removeActionListener(HTML5Video jsvideo, Object[] listeners) {
+               if (listeners == null) {
+                       for (int i = 0; i < eventTypes.length; i++) {
+                               jsvideo.removeEventListener(eventTypes[i]);
+                       }
+               }
+               
+               for (int i = 0; i < listeners.length; i += 2) {
+                       String event = (String) listeners[i];
+                       Object listener = listeners[i + 1];
+                       jsvideo.removeEventListener(event, listener);
+               }
+       }
+
+       /**
+        * Create an ImageIcon which, when placed in a JLabel, displays the video.
+        * 
+        * @param source
+        * @return
+        */
+       public static ImageIcon createIcon(Object source) {
+               try {
+                       if (source instanceof URL) {
+                               return new ImageIcon((URL) source, "jsvideo");
+                       } else if (source instanceof byte[]) {
+                               return new ImageIcon((byte[]) source, "jsvideo");
+                       } else if (source instanceof File) {
+                               return new ImageIcon(Files.readAllBytes(((File) source).toPath()));
+                       } else {
+                               return new ImageIcon(Files.readAllBytes(new File(source.toString()).toPath()));
+                       }
+               } catch (Throwable t) {
+                       return null;
+               }
+       }
+
+       /**
+        * Create a label that, when shown, displays the video.
+        * 
+        * @param source
+        * @return
+        */
+       public static JLabel createLabel(Object source) {
+               ImageIcon icon = (source instanceof ImageIcon ? (ImageIcon) source : createIcon(source));
+               return (icon == null ? null : new JLabel(icon));
+       }
+
+       /**
+        * Create a dialog that includes rudimentary controls. Optional maxWidth allows image downscaling by factors of two.
+        * 
+        * @param parent
+        * @param source 
+        * @param maxWidth
+        * @return
+        */
+       public static JDialog createDialog(Frame parent, Object source, int maxWidth, Function<HTML5Video, Void> whenReady) {
+               JDialog dialog = new JDialog(parent);
+               Container p = dialog.getContentPane();
+               p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
+               JLabel label = (source instanceof JLabel ? (JLabel) source : createLabel(source));
+               label.setAlignmentX(0.5f);
+               // not in Java! dialog.putClientProperty("jsvideo", label);
+               p.add(label);
+               label.setVisible(false);
+               p.add(getControls(label));
+               dialog.setModal(false);
+               dialog.pack();
+               dialog.setVisible(true);
+               dialog.setVisible(false);
+               HTML5Video jsvideo = (HTML5Video) label.getClientProperty("jsvideo");
+               HTML5Video.addActionListener(jsvideo, new ActionListener() {
+
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               if (label.getClientProperty("jsvideo.size") != null)
+                                       return;
+                               Dimension dim = HTML5Video.getSize(jsvideo);
+                               while (dim.width > maxWidth) {
+                                       dim.width /= 2;
+                                       dim.height /= 2;
+                               }
+                               label.putClientProperty("jsvideo.size", dim);
+                               label.setPreferredSize(dim);
+                               label.setVisible(true);
+//                             label.invalidate();
+                               dialog.pack();
+//                             dialog.setVisible(false);
+                               if (whenReady != null)
+                                       whenReady.apply(jsvideo);
+                       }
+                       
+               }, "canplaythrough");
+               HTML5Video.setCurrentTime(jsvideo,  0);
+               return dialog;
+       }
+
+       static JPanel getControls(JLabel label) {
+
+               JPanel controls = new JPanel();
+               controls.setAlignmentX(0.5f);
+               JButton btn = new JButton("play");
+               btn.addActionListener(new ActionListener() {
+
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               try {
+                                       ((HTML5Video) label.getClientProperty("jsvideo")).play();
+                               } catch (Throwable e1) {
+                                       e1.printStackTrace();
+                               }
+                       }
+
+               });
+               controls.add(btn);
+
+               btn = new JButton("pause");
+               btn.addActionListener(new ActionListener() {
+
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               try {
+                                       ((HTML5Video) label.getClientProperty("jsvideo")).pause();
+                               } catch (Throwable e1) {
+                                       e1.printStackTrace();
+                               }
+                       }
+
+               });
+               controls.add(btn);
+               
+               btn = new JButton("reset");
+               btn.addActionListener(new ActionListener() {
+
+                       @Override
+                       public void actionPerformed(ActionEvent e) {
+                               HTML5Video.setCurrentTime((HTML5Video) label.getClientProperty("jsvideo"), 0);
+                       }
+
+               });
+               controls.add(btn);
+
+               return controls;
+       }
+
+       /**
+        * Advance to the next frame, using seekToNextFrame() if available, or using the time difference supplied.
+        * 
+        * @param jsvideo
+        * @param dt  seconds to advance if seekToNextFrame() is not available
+        * @return true if can use seekToNextFrame()
+        * 
+        */
+       public static boolean nextFrame(HTML5Video jsvideo, double dt) {
+               Boolean canSeek = (Boolean) getProperty(jsvideo,"_canseek");
+               if (canSeek == null) {
+                       setProperty(jsvideo, "_canseek", canSeek = Boolean.valueOf(getProperty(jsvideo, "seekToNextFrame") != null));
+               }
+               try {                   
+                       if (canSeek) {
+                               jsvideo.seekToNextFrame();
+                       } else {
+                               HTML5Video.setCurrentTime(jsvideo, HTML5Video.getCurrentTime(jsvideo) + dt);                                            
+                       }
+               } catch (Throwable e1) {
+               }
+               return canSeek.booleanValue();
+       }
+
+       public static int getFrameCount(HTML5Video jsvideo) {
+               return (int) (getDuration(jsvideo) / 0.033334);
+       }
+
+// HTMLMediaElement properties
+
+//     audioTracks
+//     autoplay
+//     buffered Read only
+//     controller
+//     controls
+//     controlsList Read only
+//     crossOrigin
+//     currentSrc Read only
+//     currentTime
+//     defaultMuted
+//     defaultPlaybackRate
+//     disableRemotePlayback
+//     duration Read only
+//     ended Read only
+//     error Read only
+//     loop
+//     mediaGroup
+//     mediaKeys Read only
+//     mozAudioCaptured Read only
+//     mozFragmentEnd
+//     mozFrameBufferLength
+//     mozSampleRate Read only
+//     muted
+//     networkState Read only
+//     paused Read only
+//     playbackRate
+//     played Read only
+//     preload
+//     preservesPitch
+//     readyState Read only
+//     seekable Read only
+//     seeking Read only
+//     sinkId Read only
+//     src
+//     srcObject
+//     textTracks Read only
+//     videoTracks Read only
+//     volume
+//     initialTime Read only
+//     mozChannels Read only
+
+}