summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java')
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java647
1 files changed, 647 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
new file mode 100644
index 0000000000..2d220458cc
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
@@ -0,0 +1,647 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ImageResource;
+
+/**
+ * The MediaSession API provides media controls and events for a GeckoSession. This includes support
+ * for the DOM Media Session API and regular HTML media content.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaSession">Media Session
+ * API</a>
+ */
+@UiThread
+public class MediaSession {
+ private static final String LOGTAG = "MediaSession";
+ private static final boolean DEBUG = false;
+
+ private final GeckoSession mSession;
+ private boolean mIsActive;
+
+ protected MediaSession(final GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Get whether the media session is active. Only active media sessions can be controlled.
+ *
+ * <p>Changes in the active state are notified via {@link Delegate#onActivated} and {@link
+ * Delegate#onDeactivated} respectively.
+ *
+ * @see MediaSession.Delegate#onActivated
+ * @see MediaSession.Delegate#onDeactivated
+ * @return True if this media session is active, false otherwise.
+ */
+ public boolean isActive() {
+ return mIsActive;
+ }
+
+ /* package */ void setActive(final boolean active) {
+ mIsActive = active;
+ }
+
+ /** Pause playback for the media session. */
+ public void pause() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "pause");
+ }
+ mSession.getEventDispatcher().dispatch(PAUSE_EVENT, null);
+ }
+
+ /** Stop playback for the media session. */
+ public void stop() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "stop");
+ }
+ mSession.getEventDispatcher().dispatch(STOP_EVENT, null);
+ }
+
+ /** Start playback for the media session. */
+ public void play() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "play");
+ }
+ mSession.getEventDispatcher().dispatch(PLAY_EVENT, null);
+ }
+
+ /**
+ * Seek to a specific time. Prefer using fast seeking when calling this in a sequence. Don't use
+ * fast seeking for the last or only call in a sequence.
+ *
+ * @param time The time in seconds to move the playback time to.
+ * @param fast Whether fast seeking should be used.
+ */
+ public void seekTo(final double time, final boolean fast) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekTo: time=" + time + ", fast=" + fast);
+ }
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putDouble("time", time);
+ bundle.putBoolean("fast", fast);
+ mSession.getEventDispatcher().dispatch(SEEK_TO_EVENT, bundle);
+ }
+
+ /** Seek forward by a sensible number of seconds. */
+ public void seekForward() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekForward");
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putDouble("offset", 0.0);
+ mSession.getEventDispatcher().dispatch(SEEK_FORWARD_EVENT, bundle);
+ }
+
+ /** Seek backward by a sensible number of seconds. */
+ public void seekBackward() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekBackward");
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putDouble("offset", 0.0);
+ mSession.getEventDispatcher().dispatch(SEEK_BACKWARD_EVENT, bundle);
+ }
+
+ /**
+ * Select and play the next track. Move playback to the next item in the playlist when supported.
+ */
+ public void nextTrack() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "nextTrack");
+ }
+ mSession.getEventDispatcher().dispatch(NEXT_TRACK_EVENT, null);
+ }
+
+ /**
+ * Select and play the previous track. Move playback to the previous item in the playlist when
+ * supported.
+ */
+ public void previousTrack() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "previousTrack");
+ }
+ mSession.getEventDispatcher().dispatch(PREV_TRACK_EVENT, null);
+ }
+
+ /** Skip the advertisement that is currently playing. */
+ public void skipAd() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "skipAd");
+ }
+ mSession.getEventDispatcher().dispatch(SKIP_AD_EVENT, null);
+ }
+
+ /**
+ * Set whether audio should be muted. Muting audio is supported by default and does not require
+ * the media session to be active.
+ *
+ * @param mute True if audio for this media session should be muted.
+ */
+ public void muteAudio(final boolean mute) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "muteAudio=" + mute);
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putBoolean("mute", mute);
+ mSession.getEventDispatcher().dispatch(MUTE_AUDIO_EVENT, bundle);
+ }
+
+ /** Implement this delegate to receive media session events. */
+ @UiThread
+ public interface Delegate {
+ /**
+ * Notify that the given media session has become active. It is always the first event
+ * dispatched for a new or previously deactivated media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onActivated(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that the given media session has become inactive. Inactive media sessions can not be
+ * controlled.
+ *
+ * <p>TODO: Add settings links to control behavior.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onDeactivated(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify on updated metadata. Metadata may be provided by content via the DOM API or by
+ * GeckoView when not availble.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param meta The updated metadata.
+ */
+ default void onMetadata(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @NonNull final Metadata meta) {}
+
+ /**
+ * Notify on updated supported features. Unsupported actions will have no effect.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param features A combination of {@link Feature}.
+ */
+ default void onFeatures(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @MSFeature final long features) {}
+
+ /**
+ * Notify that playback has started for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onPlay(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that playback has paused for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onPause(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that playback has stopped for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onStop(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify on updated position state.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param state An instance of {@link PositionState}.
+ */
+ default void onPositionState(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @NonNull final PositionState state) {}
+
+ /**
+ * Notify on changed fullscreen state.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param enabled True when this media session in in fullscreen mode.
+ * @param meta An instance of {@link ElementMetadata}, if enabled.
+ */
+ default void onFullscreen(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ final boolean enabled,
+ @Nullable final ElementMetadata meta) {}
+ }
+
+ /** The representation of a media element's metadata. */
+ public static class ElementMetadata {
+ /** The media source URI. */
+ public final @Nullable String source;
+
+ /** The duration of the media in seconds. 0.0 if unknown. */
+ public final double duration;
+
+ /** The width of the video in device pixels. 0 if unknown. */
+ public final long width;
+
+ /** The height of the video in device pixels. 0 if unknown. */
+ public final long height;
+
+ /** The number of audio tracks contained in this element. */
+ public final int audioTrackCount;
+
+ /** The number of video tracks contained in this element. */
+ public final int videoTrackCount;
+
+ /**
+ * ElementMetadata constructor.
+ *
+ * @param source The media URI.
+ * @param duration The media duration in seconds.
+ * @param width The video width in device pixels.
+ * @param height The video height in device pixels.
+ * @param audioTrackCount The audio track count.
+ * @param videoTrackCount The video track count.
+ */
+ public ElementMetadata(
+ @Nullable final String source,
+ final double duration,
+ final long width,
+ final long height,
+ final int audioTrackCount,
+ final int videoTrackCount) {
+ this.source = source;
+ this.duration = duration;
+ this.width = width;
+ this.height = height;
+ this.audioTrackCount = audioTrackCount;
+ this.videoTrackCount = videoTrackCount;
+ }
+
+ /* package */ static @NonNull ElementMetadata fromBundle(final GeckoBundle bundle) {
+ // Sync with MediaUtils.sys.mjs.
+ return new ElementMetadata(
+ bundle.getString("src"),
+ bundle.getDouble("duration", 0.0),
+ bundle.getLong("width", 0),
+ bundle.getLong("height", 0),
+ bundle.getInt("audioTrackCount", 0),
+ bundle.getInt("videoTrackCount", 0));
+ }
+ }
+
+ /** The representation of a media session's metadata. */
+ public static class Metadata {
+ /** The media title. May be backfilled based on the document's title. May be null or empty. */
+ public final @Nullable String title;
+
+ /** The media artist name. May be null or empty. */
+ public final @Nullable String artist;
+
+ /** The media album title. May be null or empty. */
+ public final @Nullable String album;
+
+ /** The media artwork image. May be null. */
+ public final @Nullable Image artwork;
+
+ /**
+ * Metadata constructor.
+ *
+ * @param title The media title string.
+ * @param artist The media artist string.
+ * @param album The media album string.
+ * @param artwork The media artwork {@link Image}.
+ */
+ protected Metadata(
+ final @Nullable String title,
+ final @Nullable String artist,
+ final @Nullable String album,
+ final @Nullable Image artwork) {
+ this.title = title;
+ this.artist = artist;
+ this.album = album;
+ this.artwork = artwork;
+ }
+
+ @AnyThread
+ /* package */ static final class Builder {
+ private final GeckoBundle mBundle;
+
+ public Builder(final GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ public Builder(final Metadata meta) {
+ mBundle = meta.toBundle();
+ }
+
+ @NonNull
+ Builder title(final @Nullable String title) {
+ mBundle.putString("title", title);
+ return this;
+ }
+
+ @NonNull
+ Builder artist(final @Nullable String artist) {
+ mBundle.putString("artist", artist);
+ return this;
+ }
+
+ @NonNull
+ Builder album(final @Nullable String album) {
+ mBundle.putString("album", album);
+ return this;
+ }
+ }
+
+ /* package */ static @NonNull Metadata fromBundle(final GeckoBundle bundle) {
+ final GeckoBundle[] artworkBundles = bundle.getBundleArray("artwork");
+
+ final ImageResource.Collection.Builder artworkBuilder =
+ new ImageResource.Collection.Builder();
+
+ for (final GeckoBundle artworkBundle : artworkBundles) {
+ artworkBuilder.add(ImageResource.fromBundle(artworkBundle));
+ }
+
+ return new Metadata(
+ bundle.getString("title"),
+ bundle.getString("artist"),
+ bundle.getString("album"),
+ new Image(artworkBuilder.build()));
+ }
+
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(3);
+ bundle.putString("title", title);
+ bundle.putString("artist", artist);
+ bundle.putString("album", album);
+ return bundle;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Metadata {");
+ builder
+ .append(", title=")
+ .append(title)
+ .append(", artist=")
+ .append(artist)
+ .append(", album=")
+ .append(album)
+ .append(", artwork=")
+ .append(artwork)
+ .append("}");
+ return builder.toString();
+ }
+ }
+
+ /** Holds the details of the media session's playback state. */
+ public static class PositionState {
+ /** The duration of the media in seconds. */
+ public final double duration;
+
+ /** The last reported media playback position in seconds. */
+ public final double position;
+
+ /**
+ * The media playback rate coefficient. The rate is positive for forward and negative for
+ * backward playback.
+ */
+ public final double playbackRate;
+
+ /**
+ * PositionState constructor.
+ *
+ * @param duration The media duration in seconds.
+ * @param position The current media playback position in seconds.
+ * @param playbackRate The playback rate coefficient.
+ */
+ protected PositionState(
+ final double duration, final double position, final double playbackRate) {
+ this.duration = duration;
+ this.position = position;
+ this.playbackRate = playbackRate;
+ }
+
+ /* package */ static @NonNull PositionState fromBundle(final GeckoBundle bundle) {
+ return new PositionState(
+ bundle.getDouble("duration"),
+ bundle.getDouble("position"),
+ bundle.getDouble("playbackRate"));
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("PositionState {");
+ builder
+ .append("duration=")
+ .append(duration)
+ .append(", position=")
+ .append(position)
+ .append(", playbackRate=")
+ .append(playbackRate)
+ .append("}");
+ return builder.toString();
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {
+ Feature.NONE,
+ Feature.PLAY,
+ Feature.PAUSE,
+ Feature.STOP,
+ Feature.SEEK_TO,
+ Feature.SEEK_FORWARD,
+ Feature.SEEK_BACKWARD,
+ Feature.SKIP_AD,
+ Feature.NEXT_TRACK,
+ Feature.PREVIOUS_TRACK,
+ // Feature.SET_VIDEO_SURFACE
+ })
+ public @interface MSFeature {}
+
+ /** Flags for supported media session features. */
+ public static class Feature {
+ public static final long NONE = 0;
+
+ /** Playback supported. */
+ public static final long PLAY = 1 << 0;
+
+ /** Pausing supported. */
+ public static final long PAUSE = 1 << 1;
+
+ /** Stopping supported. */
+ public static final long STOP = 1 << 2;
+
+ /** Absolute seeking supported. */
+ public static final long SEEK_TO = 1 << 3;
+
+ /** Relative seeking supported (forward). */
+ public static final long SEEK_FORWARD = 1 << 4;
+
+ /** Relative seeking supported (backward). */
+ public static final long SEEK_BACKWARD = 1 << 5;
+
+ /** Skipping advertisements supported. */
+ public static final long SKIP_AD = 1 << 6;
+
+ /** Next track selection supported. */
+ public static final long NEXT_TRACK = 1 << 7;
+
+ /** Previous track selection supported. */
+ public static final long PREVIOUS_TRACK = 1 << 8;
+
+ /** Focusing supported. */
+ public static final long FOCUS = 1 << 9;
+
+ // /**
+ // * Custom video surface supported.
+ // */
+ // public static final long SET_VIDEO_SURFACE = 1 << 10;
+
+ /* package */ static long fromBundle(final GeckoBundle bundle) {
+ // Sync with MediaController.webidl.
+ final long features =
+ NONE
+ | (bundle.getBoolean("play") ? PLAY : NONE)
+ | (bundle.getBoolean("pause") ? PAUSE : NONE)
+ | (bundle.getBoolean("stop") ? STOP : NONE)
+ | (bundle.getBoolean("seekto") ? SEEK_TO : NONE)
+ | (bundle.getBoolean("seekforward") ? SEEK_FORWARD : NONE)
+ | (bundle.getBoolean("seekbackward") ? SEEK_BACKWARD : NONE)
+ | (bundle.getBoolean("nexttrack") ? NEXT_TRACK : NONE)
+ | (bundle.getBoolean("previoustrack") ? PREVIOUS_TRACK : NONE)
+ | (bundle.getBoolean("skipad") ? SKIP_AD : NONE)
+ | (bundle.getBoolean("focus") ? FOCUS : NONE);
+ return features;
+ }
+ }
+
+ private static final String ACTIVATED_EVENT = "GeckoView:MediaSession:Activated";
+ private static final String DEACTIVATED_EVENT = "GeckoView:MediaSession:Deactivated";
+ private static final String METADATA_EVENT = "GeckoView:MediaSession:Metadata";
+ private static final String POSITION_STATE_EVENT = "GeckoView:MediaSession:PositionState";
+ private static final String FEATURES_EVENT = "GeckoView:MediaSession:Features";
+ private static final String FULLSCREEN_EVENT = "GeckoView:MediaSession:Fullscreen";
+ private static final String PLAYBACK_NONE_EVENT = "GeckoView:MediaSession:Playback:None";
+ private static final String PLAYBACK_PAUSED_EVENT = "GeckoView:MediaSession:Playback:Paused";
+ private static final String PLAYBACK_PLAYING_EVENT = "GeckoView:MediaSession:Playback:Playing";
+
+ private static final String PLAY_EVENT = "GeckoView:MediaSession:Play";
+ private static final String PAUSE_EVENT = "GeckoView:MediaSession:Pause";
+ private static final String STOP_EVENT = "GeckoView:MediaSession:Stop";
+ private static final String NEXT_TRACK_EVENT = "GeckoView:MediaSession:NextTrack";
+ private static final String PREV_TRACK_EVENT = "GeckoView:MediaSession:PrevTrack";
+ private static final String SEEK_FORWARD_EVENT = "GeckoView:MediaSession:SeekForward";
+ private static final String SEEK_BACKWARD_EVENT = "GeckoView:MediaSession:SeekBackward";
+ private static final String SKIP_AD_EVENT = "GeckoView:MediaSession:SkipAd";
+ private static final String SEEK_TO_EVENT = "GeckoView:MediaSession:SeekTo";
+ private static final String MUTE_AUDIO_EVENT = "GeckoView:MediaSession:MuteAudio";
+
+ /* package */ static class Handler extends GeckoSessionHandler<MediaSession.Delegate> {
+
+ private final GeckoSession mSession;
+ private final MediaSession mMediaSession;
+
+ public Handler(final GeckoSession session) {
+ super(
+ "GeckoViewMediaControl",
+ session,
+ new String[] {
+ ACTIVATED_EVENT,
+ DEACTIVATED_EVENT,
+ METADATA_EVENT,
+ FULLSCREEN_EVENT,
+ POSITION_STATE_EVENT,
+ PLAYBACK_NONE_EVENT,
+ PLAYBACK_PAUSED_EVENT,
+ PLAYBACK_PLAYING_EVENT,
+ FEATURES_EVENT,
+ });
+ mSession = session;
+ mMediaSession = new MediaSession(session);
+ }
+
+ @Override
+ public void handleMessage(
+ final Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ }
+
+ if (ACTIVATED_EVENT.equals(event)) {
+ mMediaSession.setActive(true);
+ delegate.onActivated(mSession, mMediaSession);
+ } else if (DEACTIVATED_EVENT.equals(event)) {
+ mMediaSession.setActive(false);
+ delegate.onDeactivated(mSession, mMediaSession);
+ } else if (METADATA_EVENT.equals(event)) {
+ final Metadata meta = Metadata.fromBundle(message.getBundle("metadata"));
+ delegate.onMetadata(mSession, mMediaSession, meta);
+ } else if (POSITION_STATE_EVENT.equals(event)) {
+ final PositionState state = PositionState.fromBundle(message.getBundle("state"));
+ delegate.onPositionState(mSession, mMediaSession, state);
+ } else if (PLAYBACK_NONE_EVENT.equals(event)) {
+ delegate.onStop(mSession, mMediaSession);
+ } else if (PLAYBACK_PAUSED_EVENT.equals(event)) {
+ delegate.onPause(mSession, mMediaSession);
+ } else if (PLAYBACK_PLAYING_EVENT.equals(event)) {
+ delegate.onPlay(mSession, mMediaSession);
+ } else if (FEATURES_EVENT.equals(event)) {
+ final long features = Feature.fromBundle(message.getBundle("features"));
+ delegate.onFeatures(mSession, mMediaSession, features);
+ } else if (FULLSCREEN_EVENT.equals(event)) {
+ final boolean enabled = message.getBoolean("enabled");
+ final ElementMetadata meta = ElementMetadata.fromBundle(message.getBundle("metadata"));
+ if (!mMediaSession.isActive()) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Media session is not active yet");
+ }
+ callback.sendSuccess(false);
+ return;
+ }
+ delegate.onFullscreen(mSession, mMediaSession, enabled, meta);
+ callback.sendSuccess(true);
+ }
+ }
+ }
+}