| // Copyright 2016 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "core/html/AutoplayExperimentHelper.h" |
| |
| #include "core/dom/Document.h" |
| #include "core/frame/Settings.h" |
| #include "core/html/HTMLMediaElement.h" |
| #include "core/page/Page.h" |
| #include "platform/Logging.h" |
| #include "platform/UserGestureIndicator.h" |
| #include "platform/geometry/IntRect.h" |
| |
| namespace blink { |
| |
| using namespace HTMLNames; |
| |
| // Seconds to wait after a video has stopped moving before playing it. |
| static const double kViewportTimerPollDelay = 0.5; |
| |
| AutoplayExperimentHelper::AutoplayExperimentHelper(Client* client) |
| : m_client(client) |
| , m_mode(Mode::ExperimentOff) |
| , m_playPending(false) |
| , m_registeredWithLayoutObject(false) |
| , m_wasInViewport(false) |
| , m_autoplayMediaEncountered(false) |
| , m_playbackStartedMetricRecorded(false) |
| , m_waitingForAutoplayPlaybackStop(false) |
| , m_recordedElement(false) |
| , m_lastLocationUpdateTime(-std::numeric_limits<double>::infinity()) |
| , m_viewportTimer(this, &AutoplayExperimentHelper::viewportTimerFired) |
| , m_autoplayDeferredMetric(GesturelessPlaybackNotOverridden) |
| { |
| m_mode = fromString(this->client().autoplayExperimentMode()); |
| |
| DVLOG_IF(3, isExperimentEnabled()) << "autoplay experiment set to " << m_mode; |
| } |
| |
| AutoplayExperimentHelper::~AutoplayExperimentHelper() |
| { |
| } |
| |
| void AutoplayExperimentHelper::becameReadyToPlay() |
| { |
| // Assuming that we're eligible to override the user gesture requirement, |
| // either play if we meet the visibility checks, or install a listener |
| // to wait for them to pass. We do not actually start playback; our |
| // caller must do that. |
| autoplayMediaEncountered(); |
| |
| if (isEligible()) { |
| if (meetsVisibilityRequirements()) |
| prepareToAutoplay(GesturelessPlaybackStartedByAutoplayFlagImmediately); |
| else |
| registerForPositionUpdatesIfNeeded(); |
| } |
| } |
| |
| void AutoplayExperimentHelper::playMethodCalled() |
| { |
| // If a play is already pending, then do nothing. We're already trying |
| // to play. Similarly, do nothing if we're already playing. |
| if (m_playPending || !m_client->paused()) |
| return; |
| |
| if (!UserGestureIndicator::utilizeUserGesture()) { |
| autoplayMediaEncountered(); |
| |
| // Check for eligibility, but don't worry if playback is currently |
| // pending. If we're still not eligible, then this play() will fail. |
| if (isEligible(IgnorePendingPlayback)) { |
| m_playPending = true; |
| |
| // If we are able to override the gesture requirement now, then |
| // do so. Otherwise, install an event listener if we need one. |
| // We do not actually start playback; play() will do that. |
| if (meetsVisibilityRequirements()) { |
| // Override the gesture and assume that play() will succeed. |
| prepareToAutoplay(GesturelessPlaybackStartedByPlayMethodImmediately); |
| } else { |
| // Wait for viewport visibility. |
| // TODO(liberato): if the autoplay is allowed soon enough, then |
| // it should still record *Immediately. Otherwise, we end up |
| // here before the first layout sometimes, when the item is |
| // visible but we just don't know that yet. |
| registerForPositionUpdatesIfNeeded(); |
| } |
| } |
| } else if (isLockedPendingUserGesture()) { |
| // If this media tried to autoplay, and we haven't played it yet, then |
| // record that the user provided the gesture to start it the first time. |
| if (m_autoplayMediaEncountered && !m_playbackStartedMetricRecorded) |
| recordAutoplayMetric(AutoplayManualStart); |
| // Don't let future gestureless playbacks affect metrics. |
| m_autoplayMediaEncountered = true; |
| m_playbackStartedMetricRecorded = true; |
| m_playPending = false; |
| |
| unregisterForPositionUpdatesIfNeeded(); |
| } |
| } |
| |
| void AutoplayExperimentHelper::pauseMethodCalled() |
| { |
| // Don't try to autoplay, if we would have. |
| m_playPending = false; |
| unregisterForPositionUpdatesIfNeeded(); |
| } |
| |
| void AutoplayExperimentHelper::loadMethodCalled() |
| { |
| if (isLockedPendingUserGesture() && UserGestureIndicator::utilizeUserGesture()) { |
| recordAutoplayMetric(AutoplayEnabledThroughLoad); |
| unlockUserGesture(GesturelessPlaybackEnabledByLoad); |
| } |
| } |
| |
| void AutoplayExperimentHelper::mutedChanged() |
| { |
| // Mute changes are always allowed if this is unlocked. |
| if (!client().isLockedPendingUserGesture()) |
| return; |
| |
| // Changes with a user gesture are okay. |
| if (UserGestureIndicator::utilizeUserGesture()) |
| return; |
| |
| // If the mute state has changed to 'muted', then it's okay. |
| if (client().muted()) |
| return; |
| |
| // If nothing is playing, then changes are okay too. |
| if (client().paused()) |
| return; |
| |
| // Trying to unmute without a user gesture. |
| |
| // If we don't care about muted state, then it's okay. |
| if (!enabled(IfMuted) && !(client().isCrossOrigin() && enabled(OrMuted))) |
| return; |
| |
| // Unmuting isn't allowed, so pause. |
| client().pauseInternal(); |
| } |
| |
| void AutoplayExperimentHelper::registerForPositionUpdatesIfNeeded() |
| { |
| // If we don't require that the player is in the viewport, then we don't |
| // need the listener. |
| if (!requiresViewportVisibility()) { |
| if (!enabled(IfPageVisible)) |
| return; |
| } |
| |
| m_client->setRequestPositionUpdates(true); |
| |
| // Set this unconditionally, in case we have no layout object yet. |
| m_registeredWithLayoutObject = true; |
| } |
| |
| void AutoplayExperimentHelper::unregisterForPositionUpdatesIfNeeded() |
| { |
| if (m_registeredWithLayoutObject) |
| m_client->setRequestPositionUpdates(false); |
| |
| // Clear this unconditionally so that we don't re-register if we didn't |
| // have a LayoutObject now, but get one later. |
| m_registeredWithLayoutObject = false; |
| } |
| |
| void AutoplayExperimentHelper::positionChanged(const IntRect& visibleRect) |
| { |
| // Something, maybe position, has changed. If applicable, start a |
| // timer to look for the end of a scroll operation. |
| // Don't do much work here. |
| // Also note that we are called quite often, including when the |
| // page becomes visible. That's why we don't bother to register |
| // for page visibility changes explicitly. |
| if (visibleRect.isEmpty()) |
| return; |
| |
| m_lastVisibleRect = visibleRect; |
| |
| IntRect currentLocation = client().absoluteBoundingBoxRect(); |
| if (currentLocation.isEmpty()) |
| return; |
| |
| bool inViewport = meetsVisibilityRequirements(); |
| |
| if (m_lastLocation != currentLocation) { |
| m_lastLocationUpdateTime = monotonicallyIncreasingTime(); |
| m_lastLocation = currentLocation; |
| } |
| |
| if (inViewport && !m_wasInViewport) { |
| // Only reset the timer when we transition from not visible to |
| // visible, because resetting the timer isn't cheap. |
| m_viewportTimer.startOneShot(kViewportTimerPollDelay, BLINK_FROM_HERE); |
| } |
| m_wasInViewport = inViewport; |
| } |
| |
| void AutoplayExperimentHelper::updatePositionNotificationRegistration() |
| { |
| if (m_registeredWithLayoutObject) |
| m_client->setRequestPositionUpdates(true); |
| } |
| |
| void AutoplayExperimentHelper::triggerAutoplayViewportCheckForTesting() |
| { |
| // Make sure that the last update appears to be sufficiently far in the |
| // past to appear that scrolling has stopped by now in viewportTimerFired. |
| m_lastLocationUpdateTime = monotonicallyIncreasingTime() - kViewportTimerPollDelay - 1; |
| viewportTimerFired(nullptr); |
| } |
| |
| void AutoplayExperimentHelper::viewportTimerFired(Timer<AutoplayExperimentHelper>*) |
| { |
| double now = monotonicallyIncreasingTime(); |
| double delta = now - m_lastLocationUpdateTime; |
| if (delta < kViewportTimerPollDelay) { |
| // If we are not visible, then skip the timer. It will be started |
| // again if we become visible again. |
| if (m_wasInViewport) |
| m_viewportTimer.startOneShot(kViewportTimerPollDelay - delta, BLINK_FROM_HERE); |
| |
| return; |
| } |
| |
| // Sufficient time has passed since the last scroll that we'll |
| // treat it as the end of scroll. Autoplay if we should. |
| maybeStartPlaying(); |
| } |
| |
| bool AutoplayExperimentHelper::meetsVisibilityRequirements() const |
| { |
| if (enabled(IfPageVisible) |
| && client().pageVisibilityState() != PageVisibilityStateVisible) |
| return false; |
| |
| if (!requiresViewportVisibility()) |
| return true; |
| |
| if (m_lastVisibleRect.isEmpty()) |
| return false; |
| |
| IntRect currentLocation = client().absoluteBoundingBoxRect(); |
| if (currentLocation.isEmpty()) |
| return false; |
| |
| // In partial-viewport mode, we require only 1x1 area. |
| if (enabled(IfPartialViewport)) { |
| return m_lastVisibleRect.intersects(currentLocation); |
| } |
| |
| // Element must be completely visible, or as much as fits. |
| // If element completely fills the screen, then truncate it to exactly |
| // match the screen. Any element that is wider just has to cover. |
| if (currentLocation.x() <= m_lastVisibleRect.x() |
| && currentLocation.x() + currentLocation.width() >= m_lastVisibleRect.x() + m_lastVisibleRect.width()) { |
| currentLocation.setX(m_lastVisibleRect.x()); |
| currentLocation.setWidth(m_lastVisibleRect.width()); |
| } |
| |
| if (currentLocation.y() <= m_lastVisibleRect.y() |
| && currentLocation.y() + currentLocation.height() >= m_lastVisibleRect.y() + m_lastVisibleRect.height()) { |
| currentLocation.setY(m_lastVisibleRect.y()); |
| currentLocation.setHeight(m_lastVisibleRect.height()); |
| } |
| |
| return m_lastVisibleRect.contains(currentLocation); |
| } |
| |
| bool AutoplayExperimentHelper::maybeStartPlaying() |
| { |
| // See if we're allowed to autoplay now. |
| if (!isGestureRequirementOverridden()) |
| return false; |
| |
| // Start playing! |
| prepareToAutoplay(client().shouldAutoplay() |
| ? GesturelessPlaybackStartedByAutoplayFlagAfterScroll |
| : GesturelessPlaybackStartedByPlayMethodAfterScroll); |
| |
| // Record that this played without a user gesture. |
| // This should rarely actually do anything. Usually, playMethodCalled() |
| // and becameReadyToPlay will handle it, but toggling muted state can, |
| // in some cases, also trigger autoplay if the autoplay attribute is set |
| // after the media is ready to play. |
| autoplayMediaEncountered(); |
| |
| client().playInternal(); |
| |
| return true; |
| } |
| |
| bool AutoplayExperimentHelper::isGestureRequirementOverridden() const |
| { |
| return isEligible() && meetsVisibilityRequirements(); |
| } |
| |
| bool AutoplayExperimentHelper::isPlaybackDeferred() const |
| { |
| return m_playPending; |
| } |
| |
| bool AutoplayExperimentHelper::isEligible(EligibilityMode mode) const |
| { |
| if (m_mode == Mode::ExperimentOff) |
| return false; |
| |
| // If autoplay is disabled, no one is eligible. |
| if (!client().isAutoplayAllowedPerSettings()) |
| return false; |
| |
| // If no user gesture is required, then the experiment doesn't apply. |
| // This is what prevents us from starting playback more than once. |
| // Since this flag is never set to true once it's cleared, it will block |
| // the autoplay experiment forever. |
| if (!isLockedPendingUserGesture()) |
| return false; |
| |
| // Make sure that this is an element of the right type. |
| if (!enabled(ForVideo) && client().isHTMLVideoElement()) |
| return false; |
| |
| if (!enabled(ForAudio) && client().isHTMLAudioElement()) |
| return false; |
| |
| // If nobody has requested playback, either by the autoplay attribute or |
| // a play() call, then do nothing. |
| |
| if (mode != IgnorePendingPlayback && !m_playPending && !client().shouldAutoplay()) |
| return false; |
| |
| // Note that the viewport test always returns false on desktop, which is |
| // why video-autoplay-experiment.html doesn't check -ifmobile . |
| if (enabled(IfMobile) |
| && !client().isLegacyViewportType()) |
| return false; |
| |
| // If we require same-origin, then check the origin. |
| if (enabled(IfSameOrigin) && client().isCrossOrigin()) { |
| // We're cross-origin, so block unless it's muted content and OrMuted |
| // is enabled. For good measure, we also block all audio elements. |
| if (client().isHTMLAudioElement() || !client().muted() |
| || !enabled(OrMuted)) { |
| return false; |
| } |
| } |
| |
| // If we require muted media and this is muted, then it is eligible. |
| if (enabled(IfMuted)) |
| return client().muted(); |
| |
| // Element is eligible for gesture override, maybe muted. |
| return true; |
| } |
| |
| void AutoplayExperimentHelper::muteIfNeeded() |
| { |
| if (enabled(PlayMuted)) |
| client().setMuted(true); |
| } |
| |
| void AutoplayExperimentHelper::unlockUserGesture(AutoplayMetrics metric) |
| { |
| // Note that this could be moved back into HTMLMediaElement fairly easily. |
| // It's only here so that we can record the reason, and we can hide the |
| // ordering between unlocking and recording from the element this way. |
| if (!client().isLockedPendingUserGesture()) |
| return; |
| |
| setDeferredOverrideReason(metric); |
| client().unlockUserGesture(); |
| } |
| |
| void AutoplayExperimentHelper::setDeferredOverrideReason(AutoplayMetrics metric) |
| { |
| // If the player is unlocked, then we don't care about any later reason. |
| if (!client().isLockedPendingUserGesture()) |
| return; |
| |
| m_autoplayDeferredMetric = metric; |
| } |
| |
| void AutoplayExperimentHelper::prepareToAutoplay(AutoplayMetrics metric) |
| { |
| // This also causes !isEligible, so that we don't allow autoplay more than |
| // once. Be sure to do this before muteIfNeeded(). |
| // Also note that, at this point, we know that we're goint to start |
| // playback. However, we still don't record the metric here. Instead, |
| // we let playbackStarted() do that later. |
| setDeferredOverrideReason(metric); |
| |
| // Don't bother to call autoplayMediaEncountered, since whoever initiates |
| // playback has do it anyway, in case we don't allow autoplay. |
| |
| unregisterForPositionUpdatesIfNeeded(); |
| muteIfNeeded(); |
| |
| // Do not actually start playback here. |
| } |
| |
| AutoplayExperimentHelper::Mode AutoplayExperimentHelper::fromString(const String& mode) |
| { |
| Mode value = ExperimentOff; |
| if (mode.contains("-forvideo")) |
| value |= ForVideo; |
| if (mode.contains("-foraudio")) |
| value |= ForAudio; |
| if (mode.contains("-ifpagevisible")) |
| value |= IfPageVisible; |
| if (mode.contains("-ifviewport")) |
| value |= IfViewport; |
| if (mode.contains("-ifpartialviewport")) |
| value |= IfPartialViewport; |
| if (mode.contains("-ifmuted")) |
| value |= IfMuted; |
| if (mode.contains("-ifmobile")) |
| value |= IfMobile; |
| if (mode.contains("-ifsameorigin")) |
| value |= IfSameOrigin; |
| if (mode.contains("-ormuted")) |
| value |= OrMuted; |
| if (mode.contains("-playmuted")) |
| value |= PlayMuted; |
| |
| return value; |
| } |
| |
| void AutoplayExperimentHelper::autoplayMediaEncountered() |
| { |
| if (!m_autoplayMediaEncountered) { |
| m_autoplayMediaEncountered = true; |
| recordAutoplayMetric(AutoplayMediaFound); |
| } |
| } |
| |
| bool AutoplayExperimentHelper::isLockedPendingUserGesture() const |
| { |
| return client().isLockedPendingUserGesture(); |
| } |
| |
| void AutoplayExperimentHelper::playbackStarted() |
| { |
| recordAutoplayMetric(AnyPlaybackStarted); |
| |
| // Forget about our most recent visibility check. If another override is |
| // requested, then we'll have to refresh it. That way, we don't need to |
| // keep it up to date in the interim. |
| m_lastVisibleRect = IntRect(); |
| m_wasInViewport = false; |
| |
| // Any pending play is now playing. |
| m_playPending = false; |
| |
| if (m_playbackStartedMetricRecorded) |
| return; |
| |
| // Whether we record anything or not, we only want to record metrics for |
| // the initial playback. |
| m_playbackStartedMetricRecorded = true; |
| |
| // If this is a gestureless start, then record why it was allowed. |
| if (m_autoplayMediaEncountered) { |
| m_waitingForAutoplayPlaybackStop = true; |
| recordAutoplayMetric(m_autoplayDeferredMetric); |
| } |
| } |
| |
| void AutoplayExperimentHelper::playbackStopped() |
| { |
| const bool ended = client().ended(); |
| const bool bailout = isBailout(); |
| |
| // Record that play was paused. We don't care if it was autoplay, |
| // play(), or the user manually started it. |
| recordAutoplayMetric(ended ? AnyPlaybackComplete : AnyPlaybackPaused); |
| if (bailout) |
| recordAutoplayMetric(AnyPlaybackBailout); |
| |
| // If this was a gestureless play, then record that separately. |
| // These cover attr and play() gestureless starts. |
| if (m_waitingForAutoplayPlaybackStop) { |
| m_waitingForAutoplayPlaybackStop = false; |
| |
| recordAutoplayMetric(ended ? AutoplayComplete : AutoplayPaused); |
| |
| if (bailout) |
| recordAutoplayMetric(AutoplayBailout); |
| } |
| } |
| |
| void AutoplayExperimentHelper::recordAutoplayMetric(AutoplayMetrics metric) |
| { |
| client().recordAutoplayMetric(metric); |
| } |
| |
| bool AutoplayExperimentHelper::isBailout() const |
| { |
| // We count the user as having bailed-out on the video if they watched |
| // less than one minute and less than 50% of it. |
| const double playedTime = client().currentTime(); |
| const double progress = playedTime / client().duration(); |
| return (playedTime < 60) && (progress < 0.5); |
| } |
| |
| void AutoplayExperimentHelper::recordSandboxFailure() |
| { |
| // We record autoplayMediaEncountered here because we know |
| // that the autoplay attempt will fail. |
| autoplayMediaEncountered(); |
| recordAutoplayMetric(AutoplayDisabledBySandbox); |
| } |
| |
| void AutoplayExperimentHelper::loadingStarted() |
| { |
| if (m_recordedElement) |
| return; |
| |
| m_recordedElement = true; |
| recordAutoplayMetric(client().isHTMLVideoElement() |
| ? AnyVideoElement |
| : AnyAudioElement); |
| } |
| |
| bool AutoplayExperimentHelper::requiresViewportVisibility() const |
| { |
| return client().isHTMLVideoElement() && (enabled(IfViewport) || enabled(IfPartialViewport)); |
| } |
| |
| bool AutoplayExperimentHelper::isExperimentEnabled() |
| { |
| return m_mode != Mode::ExperimentOff; |
| } |
| |
| } // namespace blink |