summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java')
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java431
1 files changed, 431 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java b/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
new file mode 100644
index 000000000..2aa783050
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
@@ -0,0 +1,431 @@
+package org.mozilla.gecko.media;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.support.v4.app.NotificationManagerCompat;
+import android.util.Log;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.lang.ref.WeakReference;
+
+public class MediaControlService extends Service implements Tabs.OnTabsChangedListener {
+ private static final String LOGTAG = "MediaControlService";
+
+ public static final String ACTION_INIT = "action_init";
+ public static final String ACTION_RESUME = "action_resume";
+ public static final String ACTION_PAUSE = "action_pause";
+ public static final String ACTION_STOP = "action_stop";
+ public static final String ACTION_RESUME_BY_AUDIO_FOCUS = "action_resume_audio_focus";
+ public static final String ACTION_PAUSE_BY_AUDIO_FOCUS = "action_pause_audio_focus";
+
+ 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 mInitialize = false;
+ private boolean mIsMediaControlPrefOn = true;
+
+ private static WeakReference<Tab> mTabReference = new WeakReference<>(null);
+
+ private int minCoverSize;
+ private int coverSize;
+
+ @Override
+ public void onCreate() {
+ initialize();
+ }
+
+ @Override
+ public void onDestroy() {
+ shutdown();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ handleIntent(intent);
+ return START_NOT_STICKY;
+ }
+
+ @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) {
+ shutdown();
+ }
+
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ if (!mInitialize) {
+ return;
+ }
+
+ final Tab playingTab = mTabReference.get();
+ switch (msg) {
+ case MEDIA_PLAYING_CHANGE:
+ // The 'MEDIA_PLAYING_CHANGE' would only be received when the
+ // media starts or ends.
+ if (playingTab != tab && tab.isMediaPlaying()) {
+ mTabReference = new WeakReference<>(tab);
+ notifyControlInterfaceChanged(ACTION_PAUSE);
+ } else if (playingTab == tab && !tab.isMediaPlaying()) {
+ notifyControlInterfaceChanged(ACTION_STOP);
+ mTabReference = new WeakReference<>(null);
+ }
+ break;
+ case MEDIA_PLAYING_RESUME:
+ // user resume the paused-by-control media from page so that we
+ // should make the control interface consistent.
+ if (playingTab == tab && !isMediaPlaying()) {
+ notifyControlInterfaceChanged(ACTION_PAUSE);
+ }
+ break;
+ case CLOSED:
+ if (playingTab == null || playingTab == tab) {
+ // Remove the controls when the playing tab disappeared or was closed.
+ notifyControlInterfaceChanged(ACTION_STOP);
+ }
+ break;
+ case FAVICON:
+ if (playingTab == tab) {
+ final String actionForPendingIntent = isMediaPlaying() ?
+ ACTION_PAUSE : ACTION_RESUME;
+ notifyControlInterfaceChanged(actionForPendingIntent);
+ }
+ break;
+ }
+ }
+
+ private boolean isMediaPlaying() {
+ return mActionState.equals(ACTION_RESUME);
+ }
+
+ private void initialize() {
+ if (mInitialize ||
+ !isAndroidVersionLollopopOrHigher()) {
+ return;
+ }
+
+ Log.d(LOGTAG, "initialize");
+ getGeckoPreference();
+ initMediaSession();
+
+ coverSize = (int) getResources().getDimension(R.dimen.notification_media_cover);
+ minCoverSize = getResources().getDimensionPixelSize(R.dimen.favicon_bg);
+
+ Tabs.registerOnTabsChangedListener(this);
+ mInitialize = true;
+ }
+
+ private void shutdown() {
+ if (!mInitialize) {
+ return;
+ }
+
+ Log.d(LOGTAG, "shutdown");
+ notifyControlInterfaceChanged(ACTION_STOP);
+ PrefsHelper.removeObserver(mPrefsObserver);
+
+ Tabs.unregisterOnTabsChangedListener(this);
+ mInitialize = false;
+ stopSelf();
+ }
+
+ private boolean isAndroidVersionLollopopOrHigher() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP;
+ }
+
+ private void handleIntent(Intent intent) {
+ if (intent == null || intent.getAction() == null || !mInitialize) {
+ return;
+ }
+
+ Log.d(LOGTAG, "HandleIntent, action = " + intent.getAction() + ", actionState = " + mActionState);
+ switch (intent.getAction()) {
+ case ACTION_INIT :
+ // This action is used to create a service and do the initialization,
+ // the actual operation would be executed via control interface's
+ // pending intent.
+ break;
+ case ACTION_RESUME :
+ mController.getTransportControls().play();
+ break;
+ case ACTION_PAUSE :
+ mController.getTransportControls().pause();
+ break;
+ case ACTION_STOP :
+ mController.getTransportControls().stop();
+ break;
+ case ACTION_PAUSE_BY_AUDIO_FOCUS :
+ mController.getTransportControls().sendCustomAction(ACTION_PAUSE_BY_AUDIO_FOCUS, null);
+ break;
+ case ACTION_RESUME_BY_AUDIO_FOCUS :
+ mController.getTransportControls().sendCustomAction(ACTION_RESUME_BY_AUDIO_FOCUS, null);
+ 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_RESUME)) {
+ notifyControlInterfaceChanged(mIsMediaControlPrefOn ?
+ ACTION_PAUSE : ACTION_STOP);
+ }
+
+ // 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_STOP);
+ handleIntent(intent);
+ }
+ }
+ }
+ };
+ PrefsHelper.addObserver(mPrefs, mPrefsObserver);
+ }
+
+ private void initMediaSession() {
+ // 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_PAUSE_BY_AUDIO_FOCUS)) {
+ Log.d(LOGTAG, "Controller, pause by audio focus changed");
+ notifyControlInterfaceChanged(ACTION_RESUME);
+ } else if (action.equals(ACTION_RESUME_BY_AUDIO_FOCUS)) {
+ Log.d(LOGTAG, "Controller, resume by audio focus changed");
+ notifyControlInterfaceChanged(ACTION_PAUSE);
+ }
+ }
+
+ @Override
+ public void onPlay() {
+ Log.d(LOGTAG, "Controller, onPlay");
+ super.onPlay();
+ notifyControlInterfaceChanged(ACTION_PAUSE);
+ notifyObservers("MediaControl", "resumeMedia");
+ // To make sure we always own audio focus during playing.
+ AudioFocusAgent.notifyStartedPlaying();
+ }
+
+ @Override
+ public void onPause() {
+ Log.d(LOGTAG, "Controller, onPause");
+ super.onPause();
+ notifyControlInterfaceChanged(ACTION_RESUME);
+ notifyObservers("MediaControl", "mediaControlPaused");
+ AudioFocusAgent.notifyStoppedPlaying();
+ }
+
+ @Override
+ public void onStop() {
+ Log.d(LOGTAG, "Controller, onStop");
+ super.onStop();
+ notifyControlInterfaceChanged(ACTION_STOP);
+ notifyObservers("MediaControl", "mediaControlStopped");
+ mTabReference = new WeakReference<>(null);
+ }
+ });
+ }
+
+ private void notifyObservers(String topic, String data) {
+ GeckoAppShell.notifyObservers(topic, data);
+ }
+
+ private boolean isNeedToRemoveControlInterface(String action) {
+ return action.equals(ACTION_STOP);
+ }
+
+ private void notifyControlInterfaceChanged(final String uiAction) {
+ if (!mInitialize) {
+ return;
+ }
+
+ Log.d(LOGTAG, "notifyControlInterfaceChanged, action = " + uiAction);
+
+ if (isNeedToRemoveControlInterface(uiAction)) {
+ stopForeground(false);
+ NotificationManagerCompat.from(this).cancel(MEDIA_CONTROL_ID);
+ setActionState(uiAction);
+ return;
+ }
+
+ if (!mIsMediaControlPrefOn) {
+ return;
+ }
+
+ final Tab tab = mTabReference.get();
+
+ if (tab == null) {
+ return;
+ }
+
+ setActionState(uiAction);
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ updateNotification(tab, uiAction);
+ }
+ });
+ }
+
+ private void setActionState(final String uiAction) {
+ switch (uiAction) {
+ case ACTION_PAUSE:
+ mActionState = ACTION_RESUME;
+ break;
+ case ACTION_RESUME:
+ mActionState = ACTION_PAUSE;
+ break;
+ case ACTION_STOP:
+ mActionState = ACTION_STOP;
+ break;
+ }
+ }
+
+ private void updateNotification(Tab tab, String action) {
+ ThreadUtils.assertNotOnUiThread();
+
+ final Notification.MediaStyle style = new Notification.MediaStyle();
+ style.setShowActionsInCompactView(0);
+
+ final boolean isPlaying = isMediaPlaying();
+ final int visibility = tab.isPrivate() ?
+ Notification.VISIBILITY_PRIVATE : Notification.VISIBILITY_PUBLIC;
+
+ final Notification notification = new Notification.Builder(this)
+ .setSmallIcon(R.drawable.flat_icon)
+ .setLargeIcon(generateCoverArt(tab))
+ .setContentTitle(tab.getTitle())
+ .setContentText(tab.getURL())
+ .setContentIntent(createContentIntent(tab.getId()))
+ .setDeleteIntent(createDeleteIntent())
+ .setStyle(style)
+ .addAction(createNotificationAction(action))
+ .setOngoing(isPlaying)
+ .setShowWhen(false)
+ .setWhen(0)
+ .setVisibility(visibility)
+ .build();
+
+ if (isPlaying) {
+ startForeground(MEDIA_CONTROL_ID, notification);
+ } else {
+ stopForeground(false);
+ NotificationManagerCompat.from(this)
+ .notify(MEDIA_CONTROL_ID, notification);
+ }
+ }
+
+ private Notification.Action createNotificationAction(String action) {
+ boolean isPlayAction = action.equals(ACTION_RESUME);
+
+ int icon = isPlayAction ? R.drawable.ic_media_play : R.drawable.ic_media_pause;
+ String title = getString(isPlayAction ? R.string.media_play : R.string.media_pause);
+
+ final Intent intent = new Intent(getApplicationContext(), MediaControlService.class);
+ intent.setAction(action);
+ final PendingIntent pendingIntent = PendingIntent.getService(getApplicationContext(), 1, intent, 0);
+
+ //noinspection deprecation - The new constructor is only for API > 23
+ return new Notification.Action.Builder(icon, title, pendingIntent).build();
+ }
+
+ private PendingIntent createContentIntent(int tabId) {
+ Intent intent = new Intent(getApplicationContext(), BrowserApp.class);
+ intent.setAction(GeckoApp.ACTION_SWITCH_TAB);
+ intent.putExtra("TabId", tabId);
+ return PendingIntent.getActivity(getApplicationContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ private PendingIntent createDeleteIntent() {
+ Intent intent = new Intent(getApplicationContext(), MediaControlService.class);
+ intent.setAction(ACTION_STOP);
+ return PendingIntent.getService(getApplicationContext(), 1, intent, 0);
+ }
+
+ private Bitmap generateCoverArt(Tab tab) {
+ final Bitmap favicon = tab.getFavicon();
+
+ // If we do not have a favicon or if it's smaller than 72 pixels then just use the default icon.
+ if (favicon == null || favicon.getWidth() < minCoverSize || favicon.getHeight() < minCoverSize) {
+ // Use the launcher icon as fallback
+ return BitmapFactory.decodeResource(getResources(), R.drawable.notification_media);
+ }
+
+ // Favicon should at least have half of the size of the cover
+ int width = Math.max(favicon.getWidth(), coverSize / 2);
+ int height = Math.max(favicon.getHeight(), coverSize / 2);
+
+ final Bitmap coverArt = Bitmap.createBitmap(coverSize, coverSize, Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(coverArt);
+ canvas.drawColor(0xFF777777);
+
+ int left = Math.max(0, (coverArt.getWidth() / 2) - (width / 2));
+ int right = Math.min(coverSize, left + width);
+ int top = Math.max(0, (coverArt.getHeight() / 2) - (height / 2));
+ int bottom = Math.min(coverSize, top + height);
+
+ final Paint paint = new Paint();
+ paint.setAntiAlias(true);
+
+ canvas.drawBitmap(favicon,
+ new Rect(0, 0, favicon.getWidth(), favicon.getHeight()),
+ new Rect(left, top, right, bottom),
+ paint);
+
+ return coverArt;
+ }
+} \ No newline at end of file