Bug 1240423 - part1 : implement the remote media-control on Fennec.
MozReview-Commit-ID: GjkSCy5ecbQ
--- 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);
}