diff options
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.java | 431 |
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 |