Bug 1463919 - Have HTMLMediaElement ask for autoplay permission when playback otherwise blocked. r?jya draft
authorChris Pearce <cpearce@mozilla.com>
Fri, 22 Jun 2018 10:14:33 +1200
changeset 811165 12de3d9868f0920a25a45164485b906d38fa40d9
parent 811164 14558402124c3c2fdb0c67adab89b35f4d5ac3e4
child 811166 10df89785583a4edb2607b765b4dd56e3369376f
push id114216
push userbmo:cpearce@mozilla.com
push dateWed, 27 Jun 2018 03:52:23 +0000
reviewersjya
bugs1463919
milestone62.0a1
Bug 1463919 - Have HTMLMediaElement ask for autoplay permission when playback otherwise blocked. r?jya MozReview-Commit-ID: Ejv0UKBjSVf
browser/app/profile/firefox.js
dom/html/HTMLMediaElement.cpp
dom/html/HTMLMediaElement.h
dom/media/test/test_autoplay_policy_permission.html
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1443,16 +1443,18 @@ pref("media.gmp.trial-create.enabled", t
 // to enable the CDM if its disabled; it's as if the keysystem is completely
 // unsupported.
 
 #ifdef MOZ_WIDEVINE_EME
 pref("media.gmp-widevinecdm.visible", true);
 pref("media.gmp-widevinecdm.enabled", true);
 #endif
 
+pref("media.autoplay.ask-permission", false);
+
 // Play with different values of the decay time and get telemetry,
 // 0 means to randomize (and persist) the experiment value in users' profiles,
 // -1 means no experiment is run and we use the preferred value for frecency (6h)
 pref("browser.cache.frecency_experiment", 0);
 
 pref("browser.translation.detectLanguage", false);
 pref("browser.translation.neverForLanguages", "");
 // Show the translation UI bits, like the info bar, notification icon and preferences.
--- a/dom/html/HTMLMediaElement.cpp
+++ b/dom/html/HTMLMediaElement.cpp
@@ -53,16 +53,17 @@
 #include "mozilla/MathAlgorithms.h"
 #include "mozilla/NotNull.h"
 #include "mozilla/Preferences.h"
 #include "mozilla/Sprintf.h"
 #include "mozilla/StaticPrefs.h"
 #include "mozilla/Telemetry.h"
 #include "mozilla/dom/AudioTrack.h"
 #include "mozilla/dom/AudioTrackList.h"
+#include "mozilla/dom/AutoplayRequest.h"
 #include "mozilla/dom/BlobURLProtocolHandler.h"
 #include "mozilla/dom/ElementInlines.h"
 #include "mozilla/dom/HTMLAudioElement.h"
 #include "mozilla/dom/HTMLInputElement.h"
 #include "mozilla/dom/HTMLMediaElementBinding.h"
 #include "mozilla/dom/HTMLSourceElement.h"
 #include "mozilla/dom/HTMLVideoElement.h"
 #include "mozilla/dom/MediaEncryptedEvent.h"
@@ -1839,16 +1840,20 @@ HTMLMediaElement::AbortExistingLoads()
       // we destroyed the decoder, so fire a timeupdate event so that the
       // change will be reflected in the controls.
       FireTimeUpdate(false);
     }
     DispatchAsyncEvent(NS_LITERAL_STRING("emptied"));
     UpdateAudioChannelPlayingState();
   }
 
+  // Disconnect requests for permission to play. We'll make a new request
+  // if required should the new media resource try to play.
+  mAutoplayPermissionRequest.DisconnectIfExists();
+
   // We may have changed mPaused, mAutoplaying, and other
   // things which can affect AddRemoveSelfReference
   AddRemoveSelfReference();
 
   mIsRunningSelectResource = false;
 
   if (mTextTrackManager) {
     mTextTrackManager->NotifyReset();
@@ -3920,16 +3925,17 @@ HTMLMediaElement::~HTMLMediaElement()
   mShutdownObserver->Unsubscribe();
 
   if (mVideoFrameContainer) {
     mVideoFrameContainer->ForgetElement();
   }
   UnregisterActivityObserver();
 
   mSetCDMRequest.DisconnectIfExists();
+  mAutoplayPermissionRequest.DisconnectIfExists();
   if (mDecoder) {
     ShutdownDecoder();
   }
   if (mProgressTimer) {
     StopProgress();
   }
   if (mVideoDecodeSuspendTimer) {
     mVideoDecodeSuspendTimer->Cancel();
@@ -3992,89 +3998,146 @@ HTMLMediaElement::SetPlayedOrSeeked(bool
 }
 
 void
 HTMLMediaElement::NotifyXPCOMShutdown()
 {
   ShutdownDecoder();
 }
 
+bool
+HTMLMediaElement::AudioChannelAgentDelayingPlayback()
+{
+  return mAudioChannelWrapper && mAudioChannelWrapper->IsPlaybackBlocked();
+}
+
 already_AddRefed<Promise>
 HTMLMediaElement::Play(ErrorResult& aRv)
 {
   LOG(LogLevel::Debug,
       ("%p Play() called by JS readyState=%d", this, mReadyState));
 
-  if (mAudioChannelWrapper && mAudioChannelWrapper->IsPlaybackBlocked()) {
-    MaybeDoLoad();
-
-    // A blocked media element will be resumed later, so we return a pending
-    // promise which might be resolved/rejected depends on the result of
-    // resuming the blocked media element.
-    RefPtr<PlayPromise> promise = CreatePlayPromise(aRv);
-
-    if (NS_WARN_IF(aRv.Failed())) {
-      return nullptr;
-    }
-
-    LOG(LogLevel::Debug, ("%p Play() call delayed by AudioChannelAgent", this));
-
-    mPendingPlayPromises.AppendElement(promise);
-    return promise.forget();
-  }
-
-  RefPtr<Promise> promise = PlayInternal(aRv);
-
-  UpdateCustomPolicyAfterPlayed();
-
-  return promise.forget();
-}
-
-already_AddRefed<Promise>
-HTMLMediaElement::PlayInternal(ErrorResult& aRv)
-{
-  MOZ_ASSERT(!aRv.Failed());
-
-  RefPtr<PlayPromise> promise = CreatePlayPromise(aRv);
-
-  if (NS_WARN_IF(aRv.Failed())) {
-    return nullptr;
-  }
-
   // 4.8.12.8
   // When the play() method on a media element is invoked, the user agent must
   // run the following steps.
 
+  RefPtr<PlayPromise> promise = CreatePlayPromise(aRv);
+  if (NS_WARN_IF(aRv.Failed())) {
+    return nullptr;
+  }
+
   // 4.8.12.8 - Step 1:
   // If the media element is not allowed to play, return a promise rejected
   // with a "NotAllowedError" DOMException and abort these steps.
-  if (!IsAllowedToPlay()) {
-    // NOTE: for promise-based-play, will return a rejected promise here.
-    LOG(LogLevel::Debug,
-        ("%p Play() promise rejected because not allowed to play.", this));
-    promise->MaybeReject(NS_ERROR_DOM_MEDIA_NOT_ALLOWED_ERR);
-    return promise.forget();
-  }
+  // NOTE: we may require requesting permission from the user, so we do the
+  // "not allowed" check below.
 
   // 4.8.12.8 - Step 2:
   // If the media element's error attribute is not null and its code
   // attribute has the value MEDIA_ERR_SRC_NOT_SUPPORTED, return a promise
   // rejected with a "NotSupportedError" DOMException and abort these steps.
   if (GetError() && GetError()->Code() == MEDIA_ERR_SRC_NOT_SUPPORTED) {
     LOG(LogLevel::Debug,
         ("%p Play() promise rejected because source not supported.", this));
     promise->MaybeReject(NS_ERROR_DOM_MEDIA_NOT_SUPPORTED_ERR);
     return promise.forget();
   }
 
   // 4.8.12.8 - Step 3:
   // Let promise be a new promise and append promise to the list of pending
   // play promises.
+  // Note: Promise appended to list of pending promises as needed below.
+
+  if (AudioChannelAgentDelayingPlayback()) {
+    // The audio channel agent may delay starting playback of a media resource
+    // until the tab the media element is in has been in the foreground.
+    // Save a reference to the promise, and return it. The AudioChannelAgent
+    // will call Play() again if the tab is brought to the foreground, or the
+    // audio tab indicator is clicked, which will resolve the promise if we end
+    // up playing.
+    LOG(LogLevel::Debug, ("%p Play() call delayed by AudioChannelAgent", this));
+    MaybeDoLoad();
+    mPendingPlayPromises.AppendElement(promise);
+    return promise.forget();
+  }
+
+  const bool handlingUserInput = EventStateManager::IsHandlingUserInput();
+  if (IsAllowedToPlay()) {
+    mPendingPlayPromises.AppendElement(promise);
+    PlayInternal(handlingUserInput);
+    UpdateCustomPolicyAfterPlayed();
+    return promise.forget();
+  }
+
+  // Otherwise, not allowed to play. We may still be allowed to play if we
+  // ask for and are granted permission by the user.
+
+  if (!Preferences::GetBool("media.autoplay.ask-permission", false)) {
+    LOG(LogLevel::Debug, ("%p play not allowed and prompting disabled.", this));
+    promise->MaybeReject(NS_ERROR_DOM_MEDIA_NOT_ALLOWED_ERR);
+    return promise.forget();
+  }
+
+  // Prompt the user for permission to play.
   mPendingPlayPromises.AppendElement(promise);
-
+  EnsureAutoplayRequested(handlingUserInput);
+  return promise.forget();
+}
+
+void
+HTMLMediaElement::EnsureAutoplayRequested(bool aHandlingUserInput)
+{
+  if (mAutoplayPermissionRequest.Exists()) {
+    // Autoplay has already been requested in a previous play() call.
+    // Await for the previous request to be approved or denied. This
+    // play request's promise will be fulfilled with all other pending
+    // promises when the permission prompt is resolved.
+    LOG(LogLevel::Debug,
+        ("%p EnsureAutoplayRequested() existing request, bailing.", this));
+    return;
+  }
+
+  RefPtr<AutoplayRequest> request =
+    AutoplayPolicy::RequestFor(WrapNotNull(OwnerDoc()));
+  if (!request) {
+    AsyncRejectPendingPlayPromises(NS_ERROR_DOM_INVALID_STATE_ERR);
+    return;
+  }
+  RefPtr<HTMLMediaElement> self(this);
+  request->RequestWithPrompt()
+    ->Then(mAbstractMainThread,
+           __func__,
+           [ self, handlingUserInput = aHandlingUserInput, request ](
+             bool aApproved) {
+             self->mAutoplayPermissionRequest.Complete();
+             MOZ_RELEASE_ASSERT(!self->mAutoplayPermissionRequest.Exists());
+             LOG(LogLevel::Debug,
+                 ("%p Autoplay request approved request=%p",
+                  self.get(),
+                  request.get()));
+             self->PlayInternal(handlingUserInput);
+             self->UpdateCustomPolicyAfterPlayed();
+           },
+           [self, request](nsresult aError) {
+             self->mAutoplayPermissionRequest.Complete();
+             MOZ_RELEASE_ASSERT(!self->mAutoplayPermissionRequest.Exists());
+             LOG(LogLevel::Debug,
+                 ("%p Autoplay request denied request=%p",
+                  self.get(),
+                  request.get()));
+             LOG(LogLevel::Debug, ("%s rejecting play promimses", __func__));
+             self->AsyncRejectPendingPlayPromises(
+               NS_ERROR_DOM_MEDIA_NOT_ALLOWED_ERR);
+           })
+    ->Track(mAutoplayPermissionRequest);
+}
+
+void
+HTMLMediaElement::PlayInternal(bool aHandlingUserInput)
+{
   if (mPreloadAction == HTMLMediaElement::PRELOAD_NONE) {
     // The media load algorithm will be initiated by a user interaction.
     // We want to boost the channel priority for better responsiveness.
     // Note this must be done before UpdatePreloadAction() which will
     // update |mPreloadAction|.
     mUseUrgentStartForChannel = true;
   }
 
@@ -4117,17 +4180,17 @@ HTMLMediaElement::PlayInternal(ErrorResu
   AddRemoveSelfReference();
   UpdatePreloadAction();
   UpdateSrcMediaStreamPlaying();
 
   // Once play() has been called in a user generated event handler,
   // it is allowed to autoplay. Note: we can reach here when not in
   // a user generated event handler if our readyState has not yet
   // reached HAVE_METADATA.
-  mIsBlessed |= EventStateManager::IsHandlingUserInput();
+  mIsBlessed |= aHandlingUserInput;
 
   // TODO: If the playback has ended, then the user agent must set
   // seek to the effective start.
 
   // 4.8.12.8 - Step 6:
   // If the media element's paused attribute is true, run the following steps:
   if (oldPaused) {
     // 6.1. Change the value of paused to false. (Already done.)
@@ -4167,17 +4230,17 @@ HTMLMediaElement::PlayInternal(ErrorResu
     //    HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA, take pending play promises and
     //    queue a task to resolve pending play promises with the result.
     AsyncResolvePendingPlayPromises();
   }
 
   // 8. Set the media element's autoplaying flag to false. (Already done.)
 
   // 9. Return promise.
-  return promise.forget();
+  // (Done in caller.)
 }
 
 void
 HTMLMediaElement::MaybeDoLoad()
 {
   if (mNetworkState == NETWORK_EMPTY) {
     DoLoad();
   }
@@ -6053,16 +6116,17 @@ HTMLMediaElement::ChangeReadyState(nsMed
       !mLoadedDataFired) {
     DispatchAsyncEvent(NS_LITERAL_STRING("loadeddata"));
     mLoadedDataFired = true;
   }
 
   if (oldState < HAVE_FUTURE_DATA && mReadyState >= HAVE_FUTURE_DATA) {
     DispatchAsyncEvent(NS_LITERAL_STRING("canplay"));
     if (!mPaused) {
+      MOZ_ASSERT(IsAllowedToPlay());
       if (mDecoder) {
         mDecoder->Play();
       }
       NotifyAboutPlaying();
     }
   }
 
   CheckAutoplayDataReady();
@@ -7772,16 +7836,17 @@ nsTArray<RefPtr<PlayPromise>>
 HTMLMediaElement::TakePendingPlayPromises()
 {
   return std::move(mPendingPlayPromises);
 }
 
 void
 HTMLMediaElement::NotifyAboutPlaying()
 {
+  MOZ_ASSERT(IsAllowedToPlay());
   // Stick to the DispatchAsyncEvent() call path for now because we want to
   // trigger some telemetry-related codes in the DispatchAsyncEvent() method.
   DispatchAsyncEvent(NS_LITERAL_STRING("playing"));
 }
 
 already_AddRefed<PlayPromise>
 HTMLMediaElement::CreatePlayPromise(ErrorResult& aRv) const
 {
@@ -7822,16 +7887,23 @@ HTMLMediaElement::AsyncResolvePendingPla
     this, TakePendingPlayPromises());
 
   mMainThreadEventTarget->Dispatch(event.forget());
 }
 
 void
 HTMLMediaElement::AsyncRejectPendingPlayPromises(nsresult aError)
 {
+  mAutoplayPermissionRequest.DisconnectIfExists();
+
+  if (!mPaused) {
+    mPaused = true;
+    DispatchAsyncEvent(NS_LITERAL_STRING("pause"));
+  }
+
   if (mShuttingDown) {
     return;
   }
 
   nsCOMPtr<nsIRunnable> event = new nsResolveOrRejectPendingPlayPromisesRunner(
     this, TakePendingPlayPromises(), aError);
 
   mMainThreadEventTarget->Dispatch(event.forget());
--- a/dom/html/HTMLMediaElement.h
+++ b/dom/html/HTMLMediaElement.h
@@ -51,16 +51,17 @@ class DOMMediaStream;
 class ErrorResult;
 class MediaResource;
 class MediaDecoder;
 class MediaInputPort;
 class MediaStream;
 class MediaStreamGraph;
 class VideoFrameContainer;
 namespace dom {
+class AutoplayRequest;
 class MediaKeys;
 class TextTrack;
 class TimeRanges;
 class WakeLock;
 class MediaTrack;
 class MediaStreamTrack;
 class VideoStreamTrack;
 } // namespace dom
@@ -858,17 +859,17 @@ protected:
     // Tracks that were created on main thread before MediaDecoder fed them
     // to the MediaStreamGraph.
     nsTArray<RefPtr<MediaStreamTrack>> mPreCreatedTracks;
 
     // The following members are keeping state for a captured MediaStream.
     nsTArray<Pair<nsString, RefPtr<MediaInputPort>>> mTrackPorts;
   };
 
-  already_AddRefed<Promise> PlayInternal(ErrorResult& aRv);
+  void PlayInternal(bool aHandlingUserInput);
 
   /** Use this method to change the mReadyState member, so required
    * events can be fired.
    */
   void ChangeReadyState(nsMediaReadyState aState);
 
   /**
    * Use this method to change the mNetworkState member, so required
@@ -1351,16 +1352,24 @@ protected:
   void MakeAssociationWithCDMResolved();
   void SetCDMProxyFailure(const MediaResult& aResult);
   void ResetSetMediaKeysTempVariables();
 
   void PauseIfShouldNotBePlaying();
 
   WatchManager<HTMLMediaElement> mWatchManager;
 
+  // If the media element's tab has never been in the foreground, this
+  // registers as with the AudioChannelAgent to notify us when the tab
+  // is put in the foreground, whereupon we will begin playback.
+  bool AudioChannelAgentDelayingPlayback();
+
+  // Ensures we're prompting the user for permission to autoplay.
+  void EnsureAutoplayRequested(bool aHandlingUserInput);
+
   // The current decoder. Load() has been called on this decoder.
   // At most one of mDecoder and mSrcStream can be non-null.
   RefPtr<MediaDecoder> mDecoder;
 
   // The DocGroup-specific nsISerialEventTarget of this HTML element on the main
   // thread.
   nsCOMPtr<nsISerialEventTarget> mMainThreadEventTarget;
 
@@ -1558,16 +1567,19 @@ protected:
   // Encrypted Media Extension media keys.
   RefPtr<MediaKeys> mMediaKeys;
   RefPtr<MediaKeys> mIncomingMediaKeys;
   // The dom promise is used for HTMLMediaElement::SetMediaKeys.
   RefPtr<DetailedPromise> mSetMediaKeysDOMPromise;
   // Used to indicate if the MediaKeys attaching operation is on-going or not.
   bool mAttachingMediaKey = false;
   MozPromiseRequestHolder<SetCDMPromise> mSetCDMRequest;
+  // Request holder for permission prompt to autoplay. Non-null if we're
+  // currently showing a prompt for permission to autoplay.
+  MozPromiseRequestHolder<GenericPromise> mAutoplayPermissionRequest;
 
   // Stores the time at the start of the current 'played' range.
   double mCurrentPlayRangeStart = 1.0;
 
   // True if loadeddata has been fired.
   bool mLoadedDataFired = false;
 
   // Indicates whether current playback is a result of user action
--- a/dom/media/test/test_autoplay_policy_permission.html
+++ b/dom/media/test/test_autoplay_policy_permission.html
@@ -11,17 +11,18 @@
 
 <body>
   <pre id="test">
       <script>
 
         // Tests that origins with "autoplay-media" permission can autoplay.
 
         gTestPrefs.push(["media.autoplay.enabled", false],
-          ["media.autoplay.enabled.user-gestures-needed", true]);
+          ["media.autoplay.enabled.user-gestures-needed", true],
+          ["media.autoplay.ask-permission", true]);
 
         SpecialPowers.pushPrefEnv({ 'set': gTestPrefs }, () => {
           runTest();
         });
 
         async function testPlayInOrigin(testCase) {
           // Run test in a new window, to ensure its user gesture
           // activation state isn't tainted by preceeding tests.