Bug 1240423 - part1 : implement the remote media-control on Fennec. draft
authorAlastor Wu <alwu@mozilla.com>
Wed, 01 Jun 2016 10:26:01 +0800
changeset 373700 7ff551ce6361578083a3fe5a10df09246110dc94
parent 373689 280d161c1b386be777f9f642653454d424fec118
child 373701 5805e693d9b69408e8d5a847b641418a6aae67d4
push id19816
push useralwu@mozilla.com
push dateWed, 01 Jun 2016 02:38:39 +0000
bugs1240423
milestone49.0a1
Bug 1240423 - part1 : implement the remote media-control on Fennec. 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);
     }