Bug 1240423 - part1 : implement the remote media-control on Fennec. r=ahunt
authorAlastor Wu <alwu@mozilla.com>
Wed, 01 Jun 2016 10:26:01 +0800
changeset 338920 ae72a7d642b32cea0e425eb14cb6a51de68b67c6
parent 338919 e1b8a20047e913034bc74175b430ea1d544025e7
child 338921 d367637208ee027c798e09ac8b28c535a707c3a8
push id6249
push userjlund@mozilla.com
push dateMon, 01 Aug 2016 13:59:36 +0000
treeherdermozilla-beta@bad9d4f5bf7e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersahunt
bugs1240423
milestone49.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1240423 - part1 : implement the remote media-control on Fennec. r=ahunt MozReview-Commit-ID: GjkSCy5ecbQ
mobile/android/app/mobile.js
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java
mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
mobile/android/base/moz.build
toolkit/content/browser-content.js
--- a/mobile/android/app/mobile.js
+++ b/mobile/android/app/mobile.js
@@ -919,11 +919,14 @@ pref("identity.fxaccounts.remote.oauth.u
 // Token server used by Firefox Account-authenticated Sync.
 pref("identity.sync.tokenserver.uri", "https://token.services.mozilla.com/1.0/sync/1.5");
 
 // Enable Presentation API
 pref("dom.presentation.enabled", true);
 pref("dom.presentation.discovery.enabled", true);
 
 pref("dom.audiochannel.audioCompeting", true);
+// TODO : turn this pref default on in bug1264901
+pref("dom.audiochannel.mediaControl", false);
+
 // TODO : remove it after landing bug1242874 because now it's the only way to
 // suspend the MediaElement.
 pref("media.useAudioChannelAPI", true);
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -229,16 +229,20 @@
             </intent-filter>
         </receiver>
 
         <service android:name="org.mozilla.gecko.Restarter"
                  android:exported="false"
                  android:process="@MANGLED_ANDROID_PACKAGE_NAME@.Restarter">
         </service>
 
+        <service android:name="org.mozilla.gecko.media.MediaControlService"
+                 android:exported="false">
+        </service>
+
         <receiver android:name="org.mozilla.gecko.AlarmReceiver" >
         </receiver>
 
         <receiver
             android:name="org.mozilla.gecko.notifications.WhatsNewReceiver"
             android:exported="false">
             <intent-filter>
                 <action android:name="android.intent.action.PACKAGE_REPLACED" />
--- a/mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java
+++ b/mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java
@@ -1,28 +1,33 @@
 package org.mozilla.gecko.media;
 
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.annotation.WrapForJNI;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoAppShell;
 
 import android.content.Context;
+import android.content.Intent;
 import android.media.AudioManager;
 import android.media.AudioManager.OnAudioFocusChangeListener;
 import android.util.Log;
 
 public class AudioFocusAgent {
     private static final String LOGTAG = "AudioFocusAgent";
 
     private static Context mContext;
     private AudioManager mAudioManager;
     private OnAudioFocusChangeListener mAfChangeListener;
 
-    private boolean mIsOwningAudioFocus = false;
+    public static final String OWN_FOCUS = "own_focus";
+    public static final String LOST_FOCUS = "lost_focus";
+    public static final String LOST_FOCUS_TRANSIENT = "lost_focus_transient";
+
+    private String mAudioFocusState = LOST_FOCUS;
 
     @WrapForJNI
     public static void notifyStartedPlaying() {
         if (!isAttachedToContext()) {
             return;
         }
         Log.d(LOGTAG, "NotifyStartedPlaying");
         AudioFocusAgent.getInstance().requestAudioFocusIfNeeded();
@@ -46,25 +51,30 @@ public class AudioFocusAgent {
         mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
 
         mAfChangeListener = new OnAudioFocusChangeListener() {
             public void onAudioFocusChange(int focusChange) {
                 switch (focusChange) {
                     case AudioManager.AUDIOFOCUS_LOSS:
                         Log.d(LOGTAG, "onAudioFocusChange, AUDIOFOCUS_LOSS");
                         notifyObservers("AudioFocusChanged", "lostAudioFocus");
-                        // TODO : to dispatch audio-stop from gecko to trigger abandonAudioFocusIfNeeded
+                        notifyMediaControlService(MediaControlService.ACTION_PAUSE);
+                        mAudioFocusState = LOST_FOCUS;
                         break;
                     case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
                         Log.d(LOGTAG, "onAudioFocusChange, AUDIOFOCUS_LOSS_TRANSIENT");
                         notifyObservers("AudioFocusChanged", "lostAudioFocusTransiently");
+                        notifyMediaControlService(MediaControlService.ACTION_PAUSE);
+                        mAudioFocusState = LOST_FOCUS_TRANSIENT;
                         break;
                     case AudioManager.AUDIOFOCUS_GAIN:
                         Log.d(LOGTAG, "onAudioFocusChange, AUDIOFOCUS_GAIN");
                         notifyObservers("AudioFocusChanged", "gainAudioFocus");
+                        notifyMediaControlService(MediaControlService.ACTION_PLAY);
+                        mAudioFocusState = OWN_FOCUS;
                         break;
                     default:
                 }
             }
         };
     }
 
     @RobocopTarget
@@ -82,35 +92,42 @@ public class AudioFocusAgent {
 
     private void notifyObservers(String topic, String data) {
         GeckoAppShell.notifyObservers(topic, data);
     }
 
     private AudioFocusAgent() {}
 
     private void requestAudioFocusIfNeeded() {
-        if (mIsOwningAudioFocus) {
+        if (mAudioFocusState.equals(OWN_FOCUS)) {
             return;
         }
 
         int result = mAudioManager.requestAudioFocus(mAfChangeListener,
                                                      AudioManager.STREAM_MUSIC,
                                                      AudioManager.AUDIOFOCUS_GAIN);
 
         String focusMsg = (result == AudioManager.AUDIOFOCUS_GAIN) ?
             "AudioFocus request granted" : "AudioFoucs request failed";
         Log.d(LOGTAG, focusMsg);
-        // TODO : Enable media control when get the AudioFocus, see bug1240423.
         if (result == AudioManager.AUDIOFOCUS_GAIN) {
-            mIsOwningAudioFocus = true;
+            mAudioFocusState = OWN_FOCUS;
+            notifyMediaControlService(MediaControlService.ACTION_START);
         }
     }
 
     private void abandonAudioFocusIfNeeded() {
-        if (!mIsOwningAudioFocus) {
+        if (!mAudioFocusState.equals(OWN_FOCUS)) {
             return;
         }
 
         Log.d(LOGTAG, "Abandon AudioFocus");
         mAudioManager.abandonAudioFocus(mAfChangeListener);
-        mIsOwningAudioFocus = false;
+        mAudioFocusState = LOST_FOCUS;
+        notifyMediaControlService(MediaControlService.ACTION_STOP);
+    }
+
+    private void notifyMediaControlService(String action) {
+        Intent intent = new Intent(mContext, MediaControlService.class);
+        intent.setAction(action);
+        mContext.startService(intent);
     }
 }
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
@@ -0,0 +1,284 @@
+package org.mozilla.gecko.media;
+
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+import org.mozilla.gecko.PrefsHelper;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+import android.media.session.MediaSessionManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.Log;
+import android.R;
+
+public class MediaControlService extends Service {
+    private static final String LOGTAG = "MediaControlService";
+
+    public static final String ACTION_START          = "action_start";
+    public static final String ACTION_PLAY           = "action_play";
+    public static final String ACTION_PAUSE          = "action_pause";
+    public static final String ACTION_STOP           = "action_stop";
+    public static final String ACTION_REMOVE_CONTROL = "action_remove_control";
+
+    private static final int MEDIA_CONTROL_ID = 1;
+    private static final String MEDIA_CONTROL_PREF = "dom.audiochannel.mediaControl";
+
+    private String mActionState = ACTION_STOP;
+
+    private MediaSession mSession;
+    private MediaController mController;
+
+    private PrefsHelper.PrefHandler mPrefsObserver;
+    private final String[] mPrefs = { MEDIA_CONTROL_PREF };
+
+    private boolean mIsInitMediaSession = false;
+    private boolean mIsMediaControlPrefOn = true;
+
+    @Override
+    public void onCreate() {
+        getGeckoPreference();
+        initMediaSession();
+    }
+
+    @Override
+    public void onDestroy() {
+        notifyControlInterfaceChanged(ACTION_REMOVE_CONTROL);
+        PrefsHelper.removeObserver(mPrefsObserver);
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        handleIntent(intent);
+        return super.onStartCommand(intent, flags, startId);
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
+
+    @Override
+    public boolean onUnbind(Intent intent) {
+        mSession.release();
+        return super.onUnbind(intent);
+    }
+
+    @Override
+    public void onTaskRemoved(Intent rootIntent) {
+        stopSelf();
+    }
+
+    private boolean isAndroidVersionLollopopOrHigher() {
+        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP;
+    }
+
+    private void handleIntent(Intent intent) {
+        if(intent == null || intent.getAction() == null ||
+           !mIsInitMediaSession) {
+            return;
+        }
+
+        Log.d(LOGTAG, "HandleIntent, action = " + intent.getAction() + ", actionState = " + mActionState);
+        switch (intent.getAction()) {
+            case ACTION_START :
+                mController.getTransportControls().sendCustomAction(ACTION_START, null);
+                break;
+            case ACTION_PLAY :
+                mController.getTransportControls().play();
+                break;
+            case ACTION_PAUSE :
+                mController.getTransportControls().pause();
+                break;
+            case ACTION_STOP :
+                if (!mActionState.equals(ACTION_PLAY)) {
+                    return;
+                }
+                mController.getTransportControls().stop();
+                break;
+            case ACTION_REMOVE_CONTROL :
+                mController.getTransportControls().stop();
+                break;
+        }
+    }
+
+    private void getGeckoPreference() {
+        mPrefsObserver = new PrefsHelper.PrefHandlerBase() {
+            @Override
+            public void prefValue(String pref, boolean value) {
+                if (pref.equals(MEDIA_CONTROL_PREF)) {
+                    mIsMediaControlPrefOn = value;
+
+                    // If media is playing, we just need to create or remove
+                    // the media control interface.
+                    if (mActionState.equals(ACTION_PLAY)) {
+                        notifyControlInterfaceChanged(mIsMediaControlPrefOn ?
+                            ACTION_PAUSE : ACTION_REMOVE_CONTROL);
+                    }
+
+                    // If turn off pref during pausing, except removing media
+                    // interface, we also need to stop the service and notify
+                    // gecko about that.
+                    if (mActionState.equals(ACTION_PAUSE) &&
+                        !mIsMediaControlPrefOn) {
+                        Intent intent = new Intent(getApplicationContext(), MediaControlService.class);
+                        intent.setAction(ACTION_REMOVE_CONTROL);
+                        handleIntent(intent);
+                    }
+                }
+            }
+        };
+        PrefsHelper.addObserver(mPrefs, mPrefsObserver);
+    }
+
+    private void initMediaSession() {
+        if (!isAndroidVersionLollopopOrHigher() || mIsInitMediaSession) {
+            return;
+        }
+
+        // Android MediaSession is introduced since version L.
+        mSession = new MediaSession(getApplicationContext(),
+                                    "fennec media session");
+        mController = new MediaController(getApplicationContext(),
+                                          mSession.getSessionToken());
+
+        mSession.setCallback(new MediaSession.Callback() {
+            @Override
+            public void onCustomAction(String action, Bundle extras) {
+                if (action.equals(ACTION_START)) {
+                    Log.d(LOGTAG, "Controller, onStart");
+                    notifyControlInterfaceChanged(ACTION_PAUSE);
+                    mActionState = ACTION_PLAY;
+                }
+            }
+
+            @Override
+            public void onPlay() {
+                Log.d(LOGTAG, "Controller, onPlay");
+                super.onPlay();
+                notifyControlInterfaceChanged(ACTION_PAUSE);
+                notifyObservers("MediaControl", "resumeMedia");
+                mActionState = ACTION_PLAY;
+            }
+
+            @Override
+            public void onPause() {
+                Log.d(LOGTAG, "Controller, onPause");
+                super.onPause();
+                notifyControlInterfaceChanged(ACTION_PLAY);
+                notifyObservers("MediaControl", "mediaControlPaused");
+                mActionState = ACTION_PAUSE;
+            }
+
+            @Override
+            public void onStop() {
+                Log.d(LOGTAG, "Controller, onStop");
+                super.onStop();
+                notifyControlInterfaceChanged(ACTION_STOP);
+                notifyObservers("MediaControl", "mediaControlStopped");
+                mActionState = ACTION_STOP;
+                stopSelf();
+            }
+        });
+        mIsInitMediaSession = true;
+    }
+
+    private void notifyObservers(String topic, String data) {
+        GeckoAppShell.notifyObservers(topic, data);
+    }
+
+    private boolean isNeedToRemoveControlInterface(String action) {
+        return (action.equals(ACTION_STOP) ||
+                action.equals(ACTION_REMOVE_CONTROL));
+    }
+
+    private void notifyControlInterfaceChanged(String action) {
+        Log.d(LOGTAG, "notifyControlInterfaceChanged, action = " + action);
+        NotificationManager notificationManager = (NotificationManager)
+            getSystemService(Context.NOTIFICATION_SERVICE);
+
+        if (isNeedToRemoveControlInterface(action)) {
+            notificationManager.cancel(MEDIA_CONTROL_ID);
+            return;
+        }
+
+        if (!mIsMediaControlPrefOn) {
+            return;
+        }
+
+        notificationManager.notify(MEDIA_CONTROL_ID, getNotification(action));
+    }
+
+    private Notification getNotification(String action) {
+        // TODO : use website name, content and favicon in bug1264901.
+        return new Notification.Builder(this)
+            .setSmallIcon(android.R.drawable.ic_media_play)
+            .setContentTitle("Media Title")
+            .setContentText("Media Artist")
+            .setDeleteIntent(getDeletePendingIntent())
+            .setContentIntent(getClickPendingIntent())
+            .setStyle(getMediaStyle())
+            .addAction(getAction(action))
+            .setOngoing(action.equals(ACTION_PAUSE))
+            .build();
+    }
+
+    private Notification.Action getAction(String action) {
+        int icon = getActionIcon(action);
+        String title = getActionTitle(action);
+
+        Intent intent = new Intent(getApplicationContext(), MediaControlService.class);
+        intent.setAction(action);
+        PendingIntent pendingIntent = PendingIntent.getService(getApplicationContext(), 1, intent, 0);
+        return new Notification.Action.Builder(icon, title, pendingIntent).build();
+    }
+
+    private int getActionIcon(String action) {
+        switch (action) {
+            case ACTION_PLAY :
+                return android.R.drawable.ic_media_play;
+            case ACTION_PAUSE :
+                return android.R.drawable.ic_media_pause;
+            default:
+                return 0;
+        }
+    }
+
+    private String getActionTitle(String action) {
+        switch (action) {
+            case ACTION_PLAY :
+                return "Play";
+            case ACTION_PAUSE :
+                return "Pause";
+            default:
+                return null;
+        }
+    }
+
+    private PendingIntent getDeletePendingIntent() {
+        Intent intent = new Intent(getApplicationContext(), MediaControlService.class);
+        intent.setAction(ACTION_REMOVE_CONTROL);
+        return PendingIntent.getService(getApplicationContext(), 1, intent, 0);
+    }
+
+    private PendingIntent getClickPendingIntent() {
+        Intent intent = new Intent(getApplicationContext(), BrowserApp.class);
+        return PendingIntent.getActivity(getApplicationContext(), 0, intent, 0);
+    }
+
+    private Notification.MediaStyle getMediaStyle() {
+        Notification.MediaStyle style = new Notification.MediaStyle();
+        style.setShowActionsInCompactView(0);
+        return style;
+    }
+}
\ No newline at end of file
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -450,16 +450,17 @@ gbjar.sources += ['java/org/mozilla/geck
     'InputMethods.java',
     'IntentHelper.java',
     'javaaddons/JavaAddonManager.java',
     'javaaddons/JavaAddonManagerV1.java',
     'lwt/LightweightTheme.java',
     'lwt/LightweightThemeDrawable.java',
     'mdns/MulticastDNSManager.java',
     'media/AudioFocusAgent.java',
+    'media/MediaControlService.java',
     'MediaCastingBar.java',
     'MemoryMonitor.java',
     'menu/GeckoMenu.java',
     'menu/GeckoMenuInflater.java',
     'menu/GeckoMenuItem.java',
     'menu/GeckoSubMenu.java',
     'menu/MenuItemActionBar.java',
     'menu/MenuItemDefault.java',
--- a/toolkit/content/browser-content.js
+++ b/toolkit/content/browser-content.js
@@ -744,43 +744,45 @@ addMessageListener("WebChannelMessageToC
 });
 
 var AudioPlaybackListener = {
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
 
   init() {
     Services.obs.addObserver(this, "audio-playback", false);
     Services.obs.addObserver(this, "AudioFocusChanged", false);
+    Services.obs.addObserver(this, "MediaControl", false);
 
     addMessageListener("AudioPlayback", this);
     addEventListener("unload", () => {
       AudioPlaybackListener.uninit();
     });
   },
 
   uninit() {
     Services.obs.removeObserver(this, "audio-playback");
     Services.obs.removeObserver(this, "AudioFocusChanged");
+    Services.obs.removeObserver(this, "MediaControl");
 
     removeMessageListener("AudioPlayback", this);
   },
 
   handleMediaControlMessage(msg) {
     let utils = global.content.QueryInterface(Ci.nsIInterfaceRequestor)
                               .getInterface(Ci.nsIDOMWindowUtils);
     let suspendTypes = Ci.nsISuspendedTypes;
     switch (msg) {
       case "mute":
         utils.audioMuted = true;
         break;
       case "unmute":
         utils.audioMuted = false;
         break;
       case "lostAudioFocus":
-        utils.mediaSuspend = suspendTypes.SUSPENDED_STOP_DISPOSABLE;
+        utils.mediaSuspend = suspendTypes.SUSPENDED_PAUSE_DISPOSABLE;
         break;
       case "lostAudioFocusTransiently":
         utils.mediaSuspend = suspendTypes.SUSPENDED_PAUSE;
         break;
       case "gainAudioFocus":
         utils.mediaSuspend = suspendTypes.NONE_SUSPENDED;
         break;
       case "mediaControlPaused":
@@ -803,17 +805,17 @@ var AudioPlaybackListener = {
 
   observe(subject, topic, data) {
     if (topic === "audio-playback") {
       if (subject && subject.top == global.content) {
         let name = "AudioPlayback:";
         name += (data === "active") ? "Start" : "Stop";
         sendAsyncMessage(name);
       }
-    } else if (topic == "AudioFocusChanged") {
+    } else if (topic == "AudioFocusChanged" || topic == "MediaControl") {
       this.handleMediaControlMessage(data);
     }
   },
 
   receiveMessage(msg) {
     if (msg.name == "AudioPlayback") {
       this.handleMediaControlMessage(msg.data.type);
     }