Bug 1349523 - Add support for playing videos in Picture-in-picture mode; r=jchen
authorPetru Lingurar <petru.lingurar@softvision.ro>
Fri, 22 Jun 2018 12:57:02 +0300
changeset 818976 add212ee1118aff550a5fc05420282556fb3ade9
parent 818975 c6763cfa891a86b897a214a14e7c226361650bb4
child 818977 da5b3e1dca89a6ae67ec98a129515e5c11d25a7c
push id116413
push userbgrinstead@mozilla.com
push dateMon, 16 Jul 2018 22:40:17 +0000
reviewersjchen
bugs1349523
milestone63.0a1
Bug 1349523 - Add support for playing videos in Picture-in-picture mode; r=jchen MozReview-Commit-ID: DKlBFRo9q8t
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaControlAgent.java
mobile/android/base/java/org/mozilla/gecko/media/PictureInPictureController.java
mobile/android/base/locales/en-US/android_strings.dtd
mobile/android/base/strings.xml.in
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -53,16 +53,18 @@
              package hierarchy inside the Android package used to have an
              org.mozilla.{fennec,firefox,firefox_beta} subtree *and* an
              org.mozilla.gecko subtree; it now only has org.mozilla.gecko. -->
         <activity android:name="@MOZ_ANDROID_BROWSER_INTENT_CLASS@"
                   android:label="@string/moz_app_displayname"
                   android:taskAffinity="@ANDROID_PACKAGE_NAME@.BROWSER"
                   android:alwaysRetainTaskState="true"
                   android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|locale|layoutDirection|smallestScreenSize|screenLayout"
+                  android:resizeableActivity="true"
+                  android:supportsPictureInPicture="true"
                   android:windowSoftInputMode="stateUnspecified|adjustResize"
                   android:launchMode="singleTask"
                   android:exported="true"
                   android:theme="@style/Gecko.App" />
 
         <!-- Bug 1256615 / Bug 1268455: We published an .App alias and we need to maintain it
              forever.  If we don't, home screen shortcuts will disappear because the intent
              filter details change. -->
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -11,18 +11,20 @@ import android.app.Activity;
 import android.app.AlertDialog;
 import android.app.DownloadManager;
 import android.content.ContentProviderClient;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.content.SharedPreferences;
+import android.content.pm.ActivityInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
+import android.content.res.Configuration;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
 import android.graphics.Canvas;
 import android.graphics.Color;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.nfc.NdefMessage;
@@ -105,16 +107,17 @@ import org.mozilla.gecko.home.HomePanels
 import org.mozilla.gecko.home.HomeScreen;
 import org.mozilla.gecko.home.SearchEngine;
 import org.mozilla.gecko.icons.Icons;
 import org.mozilla.gecko.icons.IconsHelper;
 import org.mozilla.gecko.icons.decoders.FaviconDecoder;
 import org.mozilla.gecko.icons.decoders.IconDirectoryEntry;
 import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
 import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.media.PictureInPictureController;
 import org.mozilla.gecko.media.VideoPlayer;
 import org.mozilla.gecko.menu.GeckoMenu;
 import org.mozilla.gecko.menu.GeckoMenuItem;
 import org.mozilla.gecko.mma.MmaDelegate;
 import org.mozilla.gecko.mozglue.SafeIntent;
 import org.mozilla.gecko.notifications.NotificationHelper;
 import org.mozilla.gecko.overlays.ui.ShareDialog;
 import org.mozilla.gecko.permissions.Permissions;
@@ -232,16 +235,17 @@ public class BrowserApp extends GeckoApp
 
     private BrowserSearch mBrowserSearch;
     private View mBrowserSearchContainer;
 
     public ViewGroup mBrowserChrome;
     public ViewFlipper mActionBarFlipper;
     public ActionModeCompatView mActionBar;
     private VideoPlayer mVideoPlayer;
+    private PictureInPictureController mPipController;
     private BrowserToolbar mBrowserToolbar;
     private View doorhangerOverlay;
     // We can't name the TabStrip class because it's not included on API 9.
     private TabStripInterface mTabStrip;
     private AnimatedProgressBar mProgressView;
     private HomeScreen mHomeScreen;
     private TabsPanel mTabsPanel;
 
@@ -264,16 +268,17 @@ public class BrowserApp extends GeckoApp
 
     // When the static action bar is shown, only the real toolbar chrome should be
     // shown when the toolbar is visible. Causing the toolbar animator to also
     // show the snapshot causes the content to shift under the users finger.
     // See: Bug 1358554
     private boolean mShowingToolbarChromeForActionBar;
 
     private SafeIntent safeStartingIntent;
+    private Intent startingIntentAfterPip;
     private boolean isInAutomation;
 
     private static class MenuItemInfo implements Parcelable {
         public int id;
         public String label;
         public boolean checkable;
         public boolean checked;
         public boolean enabled = true;
@@ -859,16 +864,17 @@ public class BrowserApp extends GeckoApp
         mBrowserSearchContainer = findViewById(R.id.search_container);
         mBrowserSearch = (BrowserSearch) getSupportFragmentManager().findFragmentByTag(BROWSER_SEARCH_TAG);
         if (mBrowserSearch == null) {
             mBrowserSearch = BrowserSearch.newInstance();
             mBrowserSearch.setUserVisibleHint(false);
         }
 
         setBrowserToolbarListeners();
+        mPipController = new PictureInPictureController(this);
 
         mFindInPageBar = (FindInPageBar) findViewById(R.id.find_in_page);
         mMediaCastingBar = (MediaCastingBar) findViewById(R.id.media_casting);
 
         doorhangerOverlay = findViewById(R.id.doorhanger_overlay);
 
         EventDispatcher.getInstance().registerGeckoThreadListener(this,
             "Search:Keyword",
@@ -1179,16 +1185,48 @@ public class BrowserApp extends GeckoApp
         }
 
         for (BrowserAppDelegate delegate : delegates) {
             delegate.onPause(this);
         }
     }
 
     @Override
+    protected void onUserLeaveHint() {
+        super.onUserLeaveHint();
+        try {
+            mPipController.tryEnteringPictureInPictureMode();
+        } catch (IllegalStateException exception) {
+            Log.e(LOGTAG, "Cannot enter in Picture In Picture mode:\n" + exception.getMessage());
+        }
+    }
+
+    @Override
+    public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
+        super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
+
+        if (!isInPictureInPictureMode) {
+            mPipController.cleanResources();
+
+            // User clicked a new link to be opened in Firefox.
+            // We returned from Picture-in-picture mode and now must try to open that link.
+            if (startingIntentAfterPip != null) {
+                getApplication().startActivity(startingIntentAfterPip);
+                startingIntentAfterPip = null;
+            } else {
+                // After returning from Picture-in-picture mode the video will still be playing
+                // in fullscreen. But now we have the status bar showing.
+                // Call setFullscreen(..) to hide it and offer the same fullscreen video experience
+                // that the user had before entering in Picture-in-picture mode.
+                ActivityUtils.setFullScreen(this, true);
+            }
+        }
+    }
+
+    @Override
     public void onRestart() {
         super.onRestart();
         if (mIsAbortingAppLaunch) {
             return;
         }
 
         for (final BrowserAppDelegate delegate : delegates) {
             delegate.onRestart(this);
@@ -1234,16 +1272,22 @@ public class BrowserApp extends GeckoApp
 
     @Override
     public void onStop() {
         super.onStop();
         if (mIsAbortingAppLaunch) {
             return;
         }
 
+        if (mPipController.isInPipMode()) {
+            // If screen is locked we should exit PictureInPicture mode
+            moveTaskToBack(true);
+            mPipController.cleanResources();
+        }
+
         // We only show the guest mode notification when our activity is in the foreground.
         GuestSession.hideNotification(this);
 
         for (final BrowserAppDelegate delegate : delegates) {
             delegate.onStop(this);
         }
 
         onAfterStop();
@@ -4109,16 +4153,43 @@ public class BrowserApp extends GeckoApp
     }
 
     /*
      * If the app has been launched a certain number of times, and we haven't asked for feedback before,
      * open a new tab with about:feedback when launching the app from the icon shortcut.
      */
     @Override
     protected void onNewIntent(Intent externalIntent) {
+
+        // Currently there is no way to exit PictureInPicture mode programmatically
+        // https://issuetracker.google.com/issues/37254459
+        // but because we are "singleTask" we will receive the Intents to open a new link.
+        // When this happens, the new Intent will trigger `onPictureInPictureModeChanged(..)`
+        //
+        // Whenever the user presses a new link to be opened in Firefox we must
+        // cache the received Intent, wait for PictureInPicture mode to end and then act on that Intent.
+        if (mPipController.isInPipMode()) {
+
+            startingIntentAfterPip = externalIntent;
+
+            // Pattern used to exit MultiWindow - https://stackoverflow.com/a/43288507/4249825
+            // also works for us.
+            // Without this the old tab would continue playing media.
+            moveTaskToBack(true);
+            startingIntentAfterPip.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
+
+            // To enter PictureInPicture mode the video must be playing in fullscreen, which also
+            // means the orientation will be changed to Landscape.
+            // If when pressing the new link the device is actually in Portrait we will force
+            // the activity to enter in Portrait before opening the new tab.
+            setRequestedOrientationForCurrentActivity(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);
+
+            return;
+        }
+
         final SafeIntent intent = new SafeIntent(externalIntent);
         String action = intent.getAction();
 
         final boolean isViewAction = Intent.ACTION_VIEW.equals(action);
         final boolean isBookmarkAction = GeckoApp.ACTION_HOMESCREEN_SHORTCUT.equals(action);
         final boolean isTabQueueAction = TabQueueHelper.LOAD_URLS_ACTION.equals(action);
         final boolean isViewMultipleAction = ACTION_VIEW_MULTIPLE.equals(action);
 
--- a/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaControlAgent.java
+++ b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaControlAgent.java
@@ -24,23 +24,25 @@ import android.os.Build;
 import android.os.Bundle;
 import android.support.annotation.CheckResult;
 import android.support.annotation.NonNull;
 import android.support.annotation.VisibleForTesting;
 import android.support.v4.app.NotificationManagerCompat;
 import android.util.Log;
 
 import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.IntentHelper;
 import org.mozilla.gecko.PrefsHelper;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import static org.mozilla.gecko.BuildConfig.DEBUG;
 
 public class GeckoMediaControlAgent {
     private static final String LOGTAG = "GeckoMediaControlAgent";
 
     @SuppressLint("StaticFieldLeak")
@@ -79,17 +81,17 @@ public class GeckoMediaControlAgent {
     private int minCoverSize;
     private int coverSize;
 
     private Notification currentNotification;
 
     /**
      * Internal state of MediaControlService, to indicate it is playing media, or paused...etc.
      */
-    private State mMediaState = State.STOPPED;
+    private static State sMediaState = State.STOPPED;
 
     protected enum State {
         PLAYING,
         PAUSED,
         STOPPED
     }
 
     @RobocopTarget
@@ -150,24 +152,24 @@ public class GeckoMediaControlAgent {
         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 (mMediaState.equals(State.PLAYING)) {
+                    if (sMediaState.equals(State.PLAYING)) {
                         setState(mIsMediaControlPrefOn ? State.PLAYING : State.STOPPED);
                     }
 
                     // If turn off pref during pausing, except removing media
                     // interface, we also need to stop the service and notify
                     // gecko about that.
-                    if (mMediaState.equals(State.PAUSED) &&
+                    if (sMediaState.equals(State.PAUSED) &&
                             !mIsMediaControlPrefOn) {
                         handleAction(ACTION_STOP);
                     }
                 }
             }
         };
         PrefsHelper.addObserver(mPrefs, mPrefsObserver);
     }
@@ -233,45 +235,49 @@ public class GeckoMediaControlAgent {
                 AudioFocusAgent.getInstance().clearActiveMediaTab();
             }
         });
         mSession.setActive(true);
         return true;
     }
 
     private void notifyObservers(String topic, String data) {
+        final GeckoBundle newStatusBundle = new GeckoBundle(1);
+        newStatusBundle.putString(topic, data);
+        EventDispatcher.getInstance().dispatch("MediaControlService:MediaPlayingStatus", newStatusBundle);
+
         GeckoAppShell.notifyObservers(topic, data);
     }
 
     private boolean isNeedToRemoveControlInterface(State state) {
         return state.equals(State.STOPPED);
     }
 
     private void setState(State newState) {
-        mMediaState = newState;
-        setMediaStateForTab(mMediaState.equals(State.PLAYING));
+        sMediaState = newState;
+        setMediaStateForTab(sMediaState.equals(State.PLAYING));
         onStateChanged();
     }
 
     private void setMediaStateForTab(boolean isTabPlaying) {
         final Tab tab = AudioFocusAgent.getInstance().getActiveMediaTab();
         if (tab == null) {
             return;
         }
         tab.setIsMediaPlaying(isTabPlaying);
     }
 
     private void onStateChanged() {
         if (!mInitialized) {
             return;
         }
 
-        Log.d(LOGTAG, "onStateChanged, state = " + mMediaState);
+        Log.d(LOGTAG, "onStateChanged, state = " + sMediaState);
 
-        if (isNeedToRemoveControlInterface(mMediaState)) {
+        if (isNeedToRemoveControlInterface(sMediaState)) {
             stopForegroundService();
             NotificationManagerCompat.from(mContext).cancel(R.id.mediaControlNotification);
             release();
             return;
         }
 
         if (!mIsMediaControlPrefOn) {
             return;
@@ -286,34 +292,34 @@ public class GeckoMediaControlAgent {
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
                 updateNotification(tab);
             }
         });
     }
 
-    private boolean isMediaPlaying() {
-        return mMediaState.equals(State.PLAYING);
+    /* package */ static boolean isMediaPlaying() {
+        return sMediaState.equals(State.PLAYING);
     }
 
     public void handleAction(String action) {
         if (action == null) {
             return;
         }
 
         if (!mInitialized && action.equals(ACTION_TAB_STATE_PLAYING)) {
             initialize();
         }
 
         if (!mInitialized) {
             return;
         }
 
-        Log.d(LOGTAG, "HandleAction, action = " + action + ", mediaState = " + mMediaState);
+        Log.d(LOGTAG, "HandleAction, action = " + action + ", mediaState = " + sMediaState);
         switch (action) {
             case ACTION_RESUME :
                 mController.getTransportControls().play();
                 break;
             case ACTION_PAUSE :
                 mController.getTransportControls().pause();
                 break;
             case ACTION_STOP :
@@ -411,17 +417,17 @@ public class GeckoMediaControlAgent {
             startForegroundService();
         } else {
             stopForegroundService();
             NotificationManagerCompat.from(mContext).notify(R.id.mediaControlNotification, getCurrentNotification());
         }
     }
 
     private Notification.Action createNotificationAction() {
-        final Intent intent = createIntentUponState(mMediaState);
+        final Intent intent = createIntentUponState(sMediaState);
         boolean isPlayAction = intent.getAction().equals(ACTION_RESUME);
 
         int icon = isPlayAction ? R.drawable.ic_media_play : R.drawable.ic_media_pause;
         String title = mContext.getString(isPlayAction ? R.string.media_play : R.string.media_pause);
 
         final PendingIntent pendingIntent = PendingIntent.getService(mContext, 1, intent, 0);
 
         //noinspection deprecation - The new constructor is only for API > 23
@@ -502,17 +508,17 @@ public class GeckoMediaControlAgent {
 
     private void release() {
         if (!mInitialized) {
             return;
         }
         mInitialized = false;
 
         Log.d(LOGTAG, "release");
-        if (!mMediaState.equals(State.STOPPED)) {
+        if (!sMediaState.equals(State.STOPPED)) {
             setState(State.STOPPED);
         }
         PrefsHelper.removeObserver(mPrefsObserver);
         mHeadSetStateReceiver.unregisterReceiver(mContext);
         mSession.release();
     }
 
     private class HeadSetStateReceiver extends BroadcastReceiver {
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/PictureInPictureController.java
@@ -0,0 +1,210 @@
+package org.mozilla.gecko.media;
+
+import android.accessibilityservice.AccessibilityServiceInfo;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.app.PictureInPictureParams;
+import android.app.RemoteAction;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.drawable.Icon;
+import android.support.annotation.NonNull;
+import android.util.Log;
+import android.view.accessibility.AccessibilityManager;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BuildConfig;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ActivityUtils;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@SuppressLint("NewApi")
+public class PictureInPictureController implements BundleEventListener {
+    public static final String LOGTAG = "PictureInPictureController";
+
+    private final Activity pipActivity;
+    private boolean isInPipMode;
+
+    public PictureInPictureController(Activity activity) {
+        pipActivity = activity;
+    }
+
+    /**
+     * If the feature is supported and media is playing in fullscreen will try to activate
+     * Picture In Picture mode for the current activity.
+     * @throws IllegalStateException if entering Picture In Picture mode was not possible.
+     */
+    public void tryEnteringPictureInPictureMode() throws IllegalStateException {
+        if (shouldTryPipMode()) {
+            EventDispatcher.getInstance().registerUiThreadListener(this, "MediaControlService:MediaPlayingStatus");
+            pipActivity.enterPictureInPictureMode(getPipParams(isMediaPlaying()));
+            isInPipMode = true;
+        }
+    }
+
+    public void cleanResources() {
+        if (isInPipMode) {
+            EventDispatcher.getInstance().unregisterUiThreadListener(this, "MediaControlService:MediaPlayingStatus");
+            isInPipMode = false;
+        }
+    }
+
+    public boolean isInPipMode() {
+        return isInPipMode;
+    }
+
+    private boolean shouldTryPipMode() {
+        if (!AppConstants.Versions.feature26Plus) {
+            logDebugMessage("Picture In Picture is only available on Oreo+");
+            return false;
+        }
+
+        if (pipActivity.isChangingConfigurations()) {
+            logDebugMessage("Activity is being restarted");
+            return false;
+        }
+
+        if (pipActivity.isFinishing()) {
+            logDebugMessage("Activity is finishing");
+            return false;
+        }
+
+        // PIP might be disabled on devices that have low RAM
+        if (!pipActivity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
+            logDebugMessage("Picture In Picture mode not supported");
+            return false;
+        }
+
+        if (isScreenReaderActiveAndTroublesome()) {
+            logDebugMessage("Picture In Picture mode not supported when screen reader is active");
+            return false;
+        }
+
+        if (!ActivityUtils.isFullScreen(pipActivity)) {
+            logDebugMessage("Activity is not in fullscreen");
+            return false;
+        }
+
+        if (!isMediaPlaying()) {
+            logDebugMessage("Will not enter Picture in Picture mode if no media is playing");
+            return false;
+        }
+
+        if (pipActivity.isInPictureInPictureMode()) {
+            logDebugMessage("Activity is already in Picture In Picture " +
+                    "or Picture In Picture mode is \"Not allowed\" by the user");
+            return false;
+        }
+
+        return true;
+    }
+
+    private void updatePictureInPictureActions(@NonNull final  PictureInPictureParams params) {
+        pipActivity.setPictureInPictureParams(params);
+    }
+
+    private PictureInPictureParams getPipParams(final boolean isMediaPlaying) {
+        final List<RemoteAction> actions = new ArrayList<>(1);
+        final PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder();
+
+        actions.add(isMediaPlaying ? getPauseRemoteAction() : getPlayRemoteAction());
+        builder.setActions(actions);
+
+        return builder.build();
+    }
+
+    private RemoteAction getPauseRemoteAction() {
+        final String actionTitle = pipActivity.getString(R.string.pip_pause_button_title);
+        final String actionDescription = pipActivity.getString(R.string.pip_pause_button_description);
+
+        return new RemoteAction(getPauseIcon(), actionTitle, actionDescription, getIntentToPauseMedia());
+    }
+
+    private RemoteAction getPlayRemoteAction() {
+        final String actionTitle = pipActivity.getString(R.string.pip_play_button_title);
+        final String actionDescription = pipActivity.getString(R.string.pip_play_button_description);
+
+        return new RemoteAction(getPlayIcon(), actionTitle, actionDescription, getIntentToResumeMedia());
+    }
+
+    private PendingIntent getIntentToPauseMedia() {
+        return PendingIntent.getService(pipActivity, 0,
+                getMediaControllerIntentForAction(GeckoMediaControlAgent.ACTION_PAUSE), 0);
+    }
+
+    private PendingIntent getIntentToResumeMedia() {
+        return PendingIntent.getService(pipActivity, 0,
+                getMediaControllerIntentForAction(GeckoMediaControlAgent.ACTION_RESUME), 0);
+    }
+
+    private Icon getPauseIcon() {
+        return Icon.createWithResource(pipActivity, R.drawable.ic_media_pause);
+    }
+
+    private Icon getPlayIcon() {
+        return Icon.createWithResource(pipActivity, R.drawable.ic_media_play);
+    }
+
+    private Intent getMediaControllerIntentForAction(@NonNull final String action) {
+        return new Intent(action, null, pipActivity, MediaControlService.class);
+    }
+
+    private boolean isMediaPlaying() {
+        return GeckoMediaControlAgent.isMediaPlaying();
+    }
+
+    /**
+     * Trying to enter Picture In Picture mode on a Samsung device while an accessibility service
+     * that provides spoken feedback would result in an IllegalStateException.
+     */
+    private boolean isScreenReaderActiveAndTroublesome() {
+        final String affectedManufacturer = "samsung";
+
+        final AccessibilityManager am =
+                (AccessibilityManager) pipActivity.getSystemService(Context.ACCESSIBILITY_SERVICE);
+        final List<AccessibilityServiceInfo> enabledScreenReaderServices =
+                am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_SPOKEN);
+        final String deviceManufacturer = android.os.Build.MANUFACTURER.toLowerCase();
+
+        final boolean isScreenReaderServiceActive = !enabledScreenReaderServices.isEmpty();
+        final boolean isDeviceManufacturerAffected = affectedManufacturer.equals(deviceManufacturer);
+
+        return  isScreenReaderServiceActive && isDeviceManufacturerAffected;
+    }
+
+    private void logDebugMessage(@NonNull final String message) {
+        if (BuildConfig.DEBUG) {
+            Log.d(LOGTAG, message);
+        }
+    }
+
+    @Override
+    public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
+        String newMediaStatus = message.getString("mediaControl");
+        if (newMediaStatus == null) {
+            Log.w(LOGTAG, "Can't extract new media status");
+            return;
+        }
+        switch (newMediaStatus) {
+            case "resumeMedia":
+                updatePictureInPictureActions(getPipParams(true));
+                break;
+            case "mediaControlPaused":
+                updatePictureInPictureActions(getPipParams(false));
+                break;
+            case "mediaControlStopped":
+                updatePictureInPictureActions(getPipParams(false));
+                break;
+            default:
+                Log.w(LOGTAG, String.format("Unknown new media status: %s", newMediaStatus));
+        }
+    }
+}
--- a/mobile/android/base/locales/en-US/android_strings.dtd
+++ b/mobile/android/base/locales/en-US/android_strings.dtd
@@ -880,8 +880,15 @@ is simply hidden from the Activity Strea
 <!ENTITY pwa_add_to_launcher_confirm "+ Add to Home Screen">
 
 <!-- LOCALIZATION NOTE (pwa_add_to_launcher_badge2): Used as label in the page actions dropdown list,
 displayed when there are more than 3 actions available for a page.
 See also https://bug1409261.bmoattachments.org/attachment.cgi?id=8919897 -->
 <!ENTITY pwa_add_to_launcher_badge2 "Add to Home Screen">
 <!ENTITY pwa_continue_to_website "Continue to Website">
 <!ENTITY pwa_onboarding_sumo "You can easily add this website to your Home screen to have instant access and browse faster with an app-like experience.">
+
+<!-- Used by accessibility services to identify the play/pause buttons shown in the
+Picture-in-picture mini window -->
+<!ENTITY pip_play_button_title "Play">
+<!ENTITY pip_play_button_description "Resume playing">
+<!ENTITY pip_pause_button_title "Pause">
+<!ENTITY pip_pause_button_description "Pause playing">
--- a/mobile/android/base/strings.xml.in
+++ b/mobile/android/base/strings.xml.in
@@ -636,9 +636,14 @@
   <string name="private_tab_learn_more">&private_tab_learn_more;</string>
 
   <string name="fullscreen_warning">&fullscreen_warning;</string>
 
   <string name="pwa_add_to_launcher_confirm">&pwa_add_to_launcher_confirm;</string>
   <string name="pwa_add_to_launcher_badge">&pwa_add_to_launcher_badge2;</string>
   <string name="pwa_onboarding_sumo">&pwa_onboarding_sumo;</string>
   <string name="pwa_continue_to_website">&pwa_continue_to_website;</string>
+
+  <string name="pip_play_button_title">&pip_play_button_title;</string>
+  <string name="pip_play_button_description">&pip_play_button_description;</string>
+  <string name="pip_pause_button_title">&pip_pause_button_title;</string>
+  <string name="pip_pause_button_description">&pip_pause_button_description;</string>
 </resources>