/
githubmirror
/
amphtml
Обзор
Документация
Войти
/
githubmirror
/
amphtml
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/service/video-manager-impl.js
1 619 строк
44 KB
Viktor Szépe
Fix typos in src/ (#39606)
12 авг 2025, 21:35
Не верифицирован
12 авг 2025, 21:35
fca692e
Код
Авторство
О чём код?
import {ActionTrust_Enum} from '#core/constants/action-constants'; import {dispatchCustomEvent, removeElement} from '#core/dom'; import {measureIntersection} from '#core/dom/layout/intersection'; import {createViewportObserver} from '#core/dom/layout/viewport-observer'; import {toggle} from '#core/dom/style'; import { getInternalVideoElementFor, isAutoplaySupported, tryPlay, } from '#core/dom/video'; import {clamp} from '#core/math'; import {isFiniteNumber} from '#core/types'; import {once} from '#core/types/function'; import {map} from '#core/types/object'; import {Services} from '#service'; import { createCustomEvent, getData, listen, listenOnce, } from '#utils/event-helper'; import {dev, devAssert, user, userAssert} from '#utils/log'; import {renderIcon, renderInteractionOverlay} from './video/autoplay'; import {installAutoplayStylesForDoc} from './video/install-autoplay-styles'; import {VideoSessionManager} from './video-session-manager'; import { EMPTY_METADATA, parseFavicon, parseOgImage, parseSchemaImage, setMediaSession, validateMediaMetadata, } from '../mediasession-helper'; import {registerServiceBuilderForDoc} from '../service-helpers'; import { MIN_VISIBILITY_RATIO_FOR_AUTOPLAY, PlayingStates_Enum, VideoAnalyticsEvents_Enum, VideoAttributes_Enum, VideoEvents_Enum, VideoServiceSignals_Enum, setIsMediaComponent, userInteractedWith, videoAnalyticsCustomEventTypeKey, } from '../video-interface'; /** @private @const {string} */ const TAG = 'video-manager'; /** * @private {number} The minimum number of milliseconds to wait between each * video-seconds-played analytics event. */ const SECONDS_PLAYED_MIN_DELAY = 1000; /** * VideoManager keeps track of all AMP video players that implement * the common Video API {@see ../video-interface.VideoInterface}. * * It is responsible for providing a unified user experience and analytics for * all videos within a document. * * @implements {../service.Disposable} */ export class VideoManager { /** * @param {!./ampdoc-impl.AmpDoc} ampdoc */ constructor(ampdoc) { /** @const {!./ampdoc-impl.AmpDoc} */ this.ampdoc = ampdoc; /** @const */ this.installAutoplayStyles = once(() => installAutoplayStylesForDoc(this.ampdoc) ); /** @private {?Array<!VideoEntry>} */ this.entries_ = null; /** @private {IntersectionObserver} */ this.viewportObserver_ = null; /** * Keeps last found entry as a small optimization for multiple state calls * during one task. * @private {?VideoEntry} */ this.lastFoundEntry_ = null; /** @private @const */ this.timer_ = Services.timerFor(ampdoc.win); /** @private @const */ this.actions_ = Services.actionServiceForDoc(ampdoc.getHeadNode()); /** * @private * @const * @return {undefined} */ this.boundSecondsPlaying_ = () => this.secondsPlaying_(); /** @private @const {function():!AutoFullscreenManager} */ this.getAutoFullscreenManager_ = once( () => new AutoFullscreenManager(this.ampdoc, this) ); // TODO(cvializ, #10599): It would be nice to only create the timer // if video analytics are present, since the timer is not needed if // video analytics are not present. this.timer_.delay(this.boundSecondsPlaying_, SECONDS_PLAYED_MIN_DELAY); } /** @override */ dispose() { this.getAutoFullscreenManager_().dispose(); this.viewportObserver_.disconnect(); this.viewportObserver_ = null; if (!this.entries_) { return; } for (let i = 0; i < this.entries_.length; i++) { const entry = this.entries_[i]; entry.dispose(); } } /** * Each second, trigger video-seconds-played for videos that are playing * at trigger time. * @private */ secondsPlaying_() { for (let i = 0; i < this.entries_.length; i++) { const entry = this.entries_[i]; if (entry.getPlayingState() !== PlayingStates_Enum.PAUSED) { analyticsEvent(entry, VideoAnalyticsEvents_Enum.SECONDS_PLAYED); this.timeUpdateActionEvent_(entry); } } this.timer_.delay(this.boundSecondsPlaying_, SECONDS_PLAYED_MIN_DELAY); } /** * Triggers a LOW-TRUST timeupdate event consumable by AMP actions. * Frequency of this event is controlled by SECONDS_PLAYED_MIN_DELAY and is * every 1 second for now. * @param {!VideoEntry} entry * @private */ timeUpdateActionEvent_(entry) { const name = 'timeUpdate'; const currentTime = entry.video.getCurrentTime(); const duration = entry.video.getDuration(); if ( isFiniteNumber(currentTime) && isFiniteNumber(duration) && duration > 0 ) { const perc = currentTime / duration; const event = createCustomEvent(this.ampdoc.win, `${TAG}.${name}`, { 'time': currentTime, 'percent': perc, }); this.actions_.trigger( entry.video.element, name, event, ActionTrust_Enum.LOW ); } } // TODO(#30723): create unregister() for cleanup. /** @param {!../video-interface.VideoInterface} video */ register(video) { devAssert(video); const videoBE = /** @type {!AMP.BaseElement} */ (video); this.registerCommonActions_(video); if (!video.supportsPlatform()) { return; } if (this.getEntryOrNull_(video)) { // already registered return; } if (!this.viewportObserver_) { const viewportCallback = ( /** @type {!Array<!IntersectionObserverEntry>} */ records ) => records.forEach(({isIntersecting, target}) => { this.getEntry_(target).updateVisibility( /* isVisible */ isIntersecting ); }); this.viewportObserver_ = createViewportObserver( viewportCallback, this.ampdoc.win, {threshold: MIN_VISIBILITY_RATIO_FOR_AUTOPLAY} ); } this.viewportObserver_.observe(videoBE.element); listen(videoBE.element, VideoEvents_Enum.RELOAD, () => entry.videoLoaded()); this.entries_ = this.entries_ || []; const entry = new VideoEntry(this, video); this.entries_.push(entry); const {element} = entry.video; dispatchCustomEvent(element, VideoEvents_Enum.REGISTERED); setIsMediaComponent(element); // Unlike events, signals are permanent. We can wait for `REGISTERED` at any // moment in the element's lifecycle and the promise will resolve // appropriately each time. const signals = /** @type {!../base-element.BaseElement} */ ( video ).signals(); signals.signal(VideoEvents_Enum.REGISTERED); // Add a class to element to indicate it implements the video interface. element.classList.add('i-amphtml-video-interface'); } /** * Register common actions such as play, pause, etc... on the video element * so they can be called using AMP Actions. * For example: <button on="tap:myVideo.play"> * * @param {!../video-interface.VideoOrBaseElementDef} video * @private */ registerCommonActions_(video) { // Only require ActionTrust_Enum.LOW for video actions to defer to platform // specific handling (e.g. user gesture requirement for unmuted playback). const trust = ActionTrust_Enum.LOW; registerAction('play', () => tryPlay(video, /* isAutoplay */ false)); registerAction('pause', () => video.pause()); registerAction('mute', () => video.mute()); registerAction('unmute', () => video.unmute()); // fullscreen/fullscreenenter are a special case. // - fullscreenenter is kept as a standard name for symmetry with internal // internal interfaces // - fullscreen is an undocumented alias for backwards compatibility. const fullscreenEnter = () => video.fullscreenEnter(); registerAction('fullscreenenter', fullscreenEnter); registerAction('fullscreen', fullscreenEnter); /** * @param {string} action * @param {function()} fn */ function registerAction(action, fn) { const videoBE = /** @type {!AMP.BaseElement} */ (video); videoBE.registerAction( action, () => { userInteractedWith(video); fn(); }, trust ); } } /** * Returns the entry in the video manager corresponding to the video or * element provided, or null if unavailable. * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement * @return {?VideoEntry} entry */ getEntryOrNull_(videoOrElement) { if (isEntryFor(this.lastFoundEntry_, videoOrElement)) { return this.lastFoundEntry_; } for (let i = 0; this.entries_ && i < this.entries_.length; i++) { const entry = this.entries_[i]; if (isEntryFor(entry, videoOrElement)) { this.lastFoundEntry_ = entry; return entry; } } return null; } /** * Returns the entry in the video manager corresponding to the video or * element provided * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement * @return {VideoEntry} entry */ getEntry_(videoOrElement) { return devAssert( this.getEntryOrNull_(videoOrElement), '%s not registered to VideoManager', videoOrElement.element || videoOrElement ); } /** @param {!VideoEntry} entry */ registerForAutoFullscreen(entry) { this.getAutoFullscreenManager_().register(entry); } /** * @return {!AutoFullscreenManager} * @visibleForTesting */ getAutoFullscreenManagerForTesting_() { return this.getAutoFullscreenManager_(); } /** * Gets the current analytics details property for the given video. * Fails silently if the video is not registered. * @param {string} id * @param {string} property * @return {!Promise<string>} */ getVideoStateProperty(id, property) { const root = this.ampdoc.getRootNode(); const videoElement = user().assertElement( root.getElementById(/** @type {string} */ (id)), `Could not find an element with id="${id}" for VIDEO_STATE` ); const entry = this.getEntry_(videoElement); return (entry ? entry.getAnalyticsDetails() : Promise.resolve()).then( (details) => (details ? details[property] : '') ); } // TODO(go.amp.dev/issue/27010): For getters below, let's expose VideoEntry // instead and use directly. This is better for size and sanity. Users can // also then keep the entry reference for their own use. // (Can't expose yet due to package-level methods to be restructured, e.g // videoLoaded(). See issue) /** * Returns whether the video is paused or playing after the user interacted * with it or playing through autoplay * * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement * @return {!../video-interface.PlayingStateDef} */ getPlayingState(videoOrElement) { return this.getEntry_(videoOrElement).getPlayingState(); } /** * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement * @return {boolean} */ isMuted(videoOrElement) { return this.getEntry_(videoOrElement).isMuted(); } /** * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement * @return {boolean} */ userInteracted(videoOrElement) { return this.getEntry_(videoOrElement).userInteracted(); } /** * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement * @return {boolean} */ isRollingAd(videoOrElement) { return this.getEntry_(videoOrElement).isRollingAd(); } /** * @param {!VideoEntry} entryBeingPlayed */ pauseOtherVideos(entryBeingPlayed) { this.entries_.forEach((entry) => { if ( entry.isPlaybackManaged() && entry !== entryBeingPlayed && entry.getPlayingState() == PlayingStates_Enum.PLAYING_MANUAL ) { entry.video.pause(); } }); } } /** * @param {?VideoEntry=} entry * @param {?../video-interface.VideoOrBaseElementDef|!Element=} videoOrElement * @return {boolean} */ const isEntryFor = (entry, videoOrElement) => !!entry && (entry.video === videoOrElement || entry.video.element === videoOrElement); /** * VideoEntry represents an entry in the VideoManager's list. */ class VideoEntry { /** * @param {!VideoManager} manager * @param {!../video-interface.VideoOrBaseElementDef} video */ constructor(manager, video) { /** @private @const {!VideoManager} */ this.manager_ = manager; /** @private @const {!./ampdoc-impl.AmpDoc} */ this.ampdoc_ = manager.ampdoc; /** @package @const {!../video-interface.VideoOrBaseElementDef} */ this.video = video; /** @private {boolean} */ this.managePlayback_ = true; /** @private {boolean} */ this.loaded_ = false; /** @private {boolean} */ this.isPlaying_ = false; /** @private {boolean} */ this.isRollingAd_ = false; /** @private {boolean} */ this.isVisible_ = false; /** @private @const */ this.actionSessionManager_ = new VideoSessionManager(); this.actionSessionManager_.onSessionEnd(() => analyticsEvent(this, VideoAnalyticsEvents_Enum.SESSION) ); /** @private @const */ this.visibilitySessionManager_ = new VideoSessionManager(); this.visibilitySessionManager_.onSessionEnd(() => analyticsEvent(this, VideoAnalyticsEvents_Enum.SESSION_VISIBLE) ); /** @private @const {function(): !AnalyticsPercentageTracker} */ this.getAnalyticsPercentageTracker_ = once( () => new AnalyticsPercentageTracker(this.ampdoc_.win, this) ); // Autoplay Variables /** @private {boolean} */ this.playCalledByAutoplay_ = false; /** @private {boolean} */ this.pauseCalledByAutoplay_ = false; /** @private {?Element} */ this.internalElement_ = null; /** @private {boolean} */ this.muted_ = false; /** @private {boolean} */ this.hasSeenPlayEvent_ = false; this.hasAutoplay = video.element.hasAttribute( VideoAttributes_Enum.AUTOPLAY ); if (this.hasAutoplay) { this.manager_.installAutoplayStyles(); } // Media Session API Variables /** @private {!../mediasession-helper.MetadataDef} */ this.metadata_ = EMPTY_METADATA; /** @private @const {function()} */ this.boundMediasessionPlay_ = () => { tryPlay(this.video, /* isAutoplay */ false); }; /** @private @const {function()} */ this.boundMediasessionPause_ = () => { this.video.pause(); }; listen(video.element, VideoEvents_Enum.LOAD, () => this.videoLoaded()); listen(video.element, VideoEvents_Enum.PAUSE, () => this.videoPaused_()); listen(video.element, VideoEvents_Enum.PLAY, () => { this.hasSeenPlayEvent_ = true; analyticsEvent(this, VideoAnalyticsEvents_Enum.PLAY); }); listen(video.element, VideoEvents_Enum.PLAYING, () => this.videoPlayed_()); listen(video.element, VideoEvents_Enum.MUTED, () => (this.muted_ = true)); listen(video.element, VideoEvents_Enum.UNMUTED, () => { this.muted_ = false; this.manager_.pauseOtherVideos(this); }); listen(video.element, VideoEvents_Enum.CUSTOM_TICK, (e) => { const data = getData(e); const eventType = data['eventType']; if (!eventType) { // CUSTOM_TICK is a generic event for 3p players whose semantics // don't fit with other video events. // If `eventType` is unset, it's not meant for analytics. return; } this.logCustomAnalytics_(eventType, data['vars']); }); listen(video.element, VideoEvents_Enum.ENDED, () => { this.isRollingAd_ = false; analyticsEvent(this, VideoAnalyticsEvents_Enum.ENDED); }); listen(video.element, VideoEvents_Enum.AD_START, () => { this.isRollingAd_ = true; analyticsEvent(this, VideoAnalyticsEvents_Enum.AD_START); }); listen(video.element, VideoEvents_Enum.AD_END, () => { this.isRollingAd_ = false; analyticsEvent(this, VideoAnalyticsEvents_Enum.AD_END); }); video .signals() .whenSignal(VideoEvents_Enum.REGISTERED) .then(() => this.onRegister_()); /** * Trigger event for first manual play. * @private @const {!function()} */ this.firstPlayEventOrNoop_ = once(() => { const firstPlay = 'firstPlay'; const trust = ActionTrust_Enum.LOW; const event = createCustomEvent(this.ampdoc_.win, firstPlay, {}); const {element} = this.video; const actions = Services.actionServiceForDoc(element); actions.trigger(element, firstPlay, event, trust); }); this.listenForPlaybackDelegation_(); } /** @public */ dispose() { this.getAnalyticsPercentageTracker_().stop(); } /** * @param {string} eventType * @param {!{[key: string]: string}} vars */ logCustomAnalytics_(eventType, vars) { const prefixedVars = {[videoAnalyticsCustomEventTypeKey]: eventType}; Object.keys(vars).forEach((key) => { prefixedVars[`custom_${key}`] = vars[key]; }); analyticsEvent(this, VideoAnalyticsEvents_Enum.CUSTOM, prefixedVars); } /** Listens for signals to delegate playback to a different module. */ listenForPlaybackDelegation_() { const signals = this.video.signals(); signals.whenSignal(VideoServiceSignals_Enum.PLAYBACK_DELEGATED).then(() => { this.managePlayback_ = false; if (this.isPlaying_) { this.video.pause(); } }); } /** @return {boolean} */ isMuted() { return this.muted_; } /** @return {boolean} */ isPlaybackManaged() { return this.managePlayback_; } /** @private */ onRegister_() { if (this.requiresAutoFullscreen_()) { this.manager_.registerForAutoFullscreen(this); } if (this.hasAutoplay) { this.autoplayVideoBuilt_(); } } /** * @return {boolean} * @private */ requiresAutoFullscreen_() { const {element} = this.video; if ( this.video.preimplementsAutoFullscreen() || !element.hasAttribute(VideoAttributes_Enum.ROTATE_TO_FULLSCREEN) ) { return false; } return userAssert( this.video.isInteractive(), 'Only interactive videos are allowed to enter fullscreen on rotate. ' + 'Set the `controls` attribute on %s to enable.', element ); } /** * Callback for when the video starts playing * @private */ videoPlayed_() { this.isPlaying_ = true; if (this.getPlayingState() == PlayingStates_Enum.PLAYING_MANUAL) { this.firstPlayEventOrNoop_(); this.manager_.pauseOtherVideos(this); } const {video} = this; const {element} = video; if ( !video.preimplementsMediaSessionAPI() && !element.classList.contains('i-amphtml-disable-mediasession') ) { validateMediaMetadata(element, this.metadata_); setMediaSession( this.ampdoc_.win, this.metadata_, this.boundMediasessionPlay_, this.boundMediasessionPause_ ); } this.actionSessionManager_.beginSession(); if (this.isVisible_) { this.visibilitySessionManager_.beginSession(); } // The PLAY event was omitted from the original VideoInterface. Thus // not every implementation emits it. It should always happen before // PLAYING. Hence we treat the PLAYING as an indication to emit the // Analytics PLAY event if we haven't seen PLAY. if (!this.hasSeenPlayEvent_) { analyticsEvent(this, VideoAnalyticsEvents_Enum.PLAY); } } /** * Callback for when the video has been paused * @private */ videoPaused_() { analyticsEvent(this, VideoAnalyticsEvents_Enum.PAUSE); this.isPlaying_ = false; // Prevent double-trigger of session if video is autoplay and the video // is paused by a the user scrolling the video out of view. if (!this.pauseCalledByAutoplay_) { this.actionSessionManager_.endSession(); } else { // reset the flag this.pauseCalledByAutoplay_ = false; } } /** * Called when the video is loaded and can play. */ videoLoaded() { this.loaded_ = true; this.internalElement_ = getInternalVideoElementFor(this.video.element); this.fillMediaSessionMetadata_(); this.getAnalyticsPercentageTracker_().start(); if (this.isVisible_) { // Handles the case when the video becomes visible before loading this.loadedVideoVisibilityChanged_(); } } /** * Gets the provided metadata and fills in missing fields * @private */ fillMediaSessionMetadata_() { if (this.video.preimplementsMediaSessionAPI()) { return; } if (this.video.getMetadata()) { this.metadata_ = map( /** @type {!../mediasession-helper.MetadataDef} */ (this.video.getMetadata()) ); } const doc = this.ampdoc_.win.document; if (!this.metadata_.artwork || this.metadata_.artwork.length == 0) { const posterUrl = parseSchemaImage(doc) || parseOgImage(doc) || parseFavicon(doc); if (posterUrl) { this.metadata_.artwork = [ { 'src': posterUrl, }, ]; } } if (!this.metadata_.title) { const title = this.video.element.getAttribute('title') || this.video.element.getAttribute('aria-label') || this.internalElement_.getAttribute('title') || this.internalElement_.getAttribute('aria-label') || doc.title; if (title) { this.metadata_.title = title; } } } /** * Called when visibility of a video changes. * @private */ videoVisibilityChanged_() { if (this.loaded_) { this.loadedVideoVisibilityChanged_(); } } /** * Only called when visibility of a loaded video changes. * @private */ loadedVideoVisibilityChanged_() { if (!this.ampdoc_.isVisible()) { return; } isAutoplaySupported(this.ampdoc_.win).then((isAutoplaySupported) => { const canAutoplay = this.hasAutoplay && !this.userInteracted(); if (canAutoplay && isAutoplaySupported) { this.autoplayLoadedVideoVisibilityChanged_(); } else { this.nonAutoplayLoadedVideoVisibilityChanged_(); } }); } /* Autoplay Behavior */ /** * Called when an autoplay video is built. * @private */ autoplayVideoBuilt_() { // Hide controls until we know if autoplay is supported, otherwise hiding // and showing the controls quickly becomes a bad user experience for the // common case where autoplay is supported. if (this.video.isInteractive()) { this.video.hideControls(); } isAutoplaySupported(this.ampdoc_.win).then((isAutoplaySupported) => { if (!isAutoplaySupported && this.video.isInteractive()) { // Autoplay is not supported, show the controls so user can manually // initiate playback. this.video.showControls(); return; } // Only muted videos are allowed to autoplay this.video.mute(); this.installAutoplayElements_(); }); } /** * Installs autoplay animation and interaction mask when interactive. * The animated icon is appended always, but only displayed by CSS when * `controls` is set. See `video-autoplay.css`. * @private */ installAutoplayElements_() { const {video} = this; const {element, win} = this.video; if ( element.hasAttribute(VideoAttributes_Enum.NO_AUDIO) || element.signals().get(VideoServiceSignals_Enum.USER_INTERACTED) ) { return; } const animation = renderIcon(win, element); const children = [animation]; /** @param {boolean} shouldDisplay */ function toggleElements(shouldDisplay) { video.mutateElementSkipRemeasure(() => { children.forEach((child) => { toggle(child, shouldDisplay); }); }); } /** @param {boolean} isPlaying */ function toggleAnimation(isPlaying) { video.mutateElementSkipRemeasure(() => animation.classList.toggle('amp-video-eq-play', isPlaying) ); } const unlisteners = [ listen(element, VideoEvents_Enum.PAUSE, () => toggleAnimation(false)), listen(element, VideoEvents_Enum.PLAYING, () => toggleAnimation(true)), listen(element, VideoEvents_Enum.AD_START, () => { toggleElements(false); video.showControls(); }), listen(element, VideoEvents_Enum.AD_END, () => { toggleElements(true); video.hideControls(); }), listen(element, VideoEvents_Enum.UNMUTED, () => userInteractedWith(video) ), ]; if (video.isInteractive()) { video.hideControls(); const mask = renderInteractionOverlay(element, this.metadata_); children.push(mask); unlisteners.push(listen(mask, 'click', () => userInteractedWith(video))); } video.mutateElementSkipRemeasure(() => { children.forEach((child) => { element.appendChild(child); }); }); if (this.isRollingAd_) { toggleElements(false); } video .signals() .whenSignal(VideoServiceSignals_Enum.USER_INTERACTED) .then(() => { this.firstPlayEventOrNoop_(); if (video.isInteractive()) { video.showControls(); } video.unmute(); unlisteners.forEach((unlistener) => { unlistener(); }); video.mutateElementSkipRemeasure(() => { children.forEach((child) => { removeElement(child); }); }); }); } /** * Called when visibility of a loaded autoplay video changes. * @private */ autoplayLoadedVideoVisibilityChanged_() { if (!this.managePlayback_) { return; } if (this.isVisible_) { this.visibilitySessionManager_.beginSession(); tryPlay(this.video, /*autoplay*/ true); this.playCalledByAutoplay_ = true; } else { if (this.isPlaying_) { this.visibilitySessionManager_.endSession(); } this.video.pause(); this.pauseCalledByAutoplay_ = true; } } /** * Called when visibility of a loaded non-autoplay video changes. * @private */ nonAutoplayLoadedVideoVisibilityChanged_() { if (this.isVisible_) { this.visibilitySessionManager_.beginSession(); } else if (this.isPlaying_) { this.visibilitySessionManager_.endSession(); } } /** * Called by an IntersectionObserver. * @param {boolean} isVisible * @package */ updateVisibility(isVisible) { const wasVisible = this.isVisible_; this.isVisible_ = isVisible; if (isVisible != wasVisible) { this.videoVisibilityChanged_(); } } /** * Returns whether the video is paused or playing after the user interacted * with it or playing through autoplay * @return {!../video-interface.PlayingStateDef} */ getPlayingState() { if (!this.isPlaying_) { return PlayingStates_Enum.PAUSED; } if ( this.isPlaying_ && this.playCalledByAutoplay_ && !this.userInteracted() ) { return PlayingStates_Enum.PLAYING_AUTO; } return PlayingStates_Enum.PLAYING_MANUAL; } /** @return {boolean} */ isRollingAd() { return this.isRollingAd_; } /** * Returns whether the video was interacted with or not * @return {boolean} */ userInteracted() { return ( this.video.signals().get(VideoServiceSignals_Enum.USER_INTERACTED) != null ); } /** * Collects a snapshot of the current video state for video analytics * @return {!Promise<!VideoAnalyticsDetailsDef>} */ getAnalyticsDetails() { const {video} = this; return Promise.all([ isAutoplaySupported(this.ampdoc_.win), measureIntersection(video.element), ]).then((responses) => { const isAutoplaySupported = /** @type {boolean} */ (responses[0]); const intersection = /** @type {!IntersectionObserverEntry} */ ( responses[1] ); const {height, width} = intersection.boundingClientRect; const autoplay = this.hasAutoplay && isAutoplaySupported; const playedRanges = video.getPlayedRanges(); const playedTotal = playedRanges.reduce( (acc, range) => acc + range[1] - range[0], 0 ); return { 'autoplay': autoplay, 'currentTime': video.getCurrentTime(), 'duration': video.getDuration(), // TODO(cvializ): add fullscreen 'height': height, 'id': video.element.id, 'muted': this.muted_, 'playedTotal': playedTotal, 'playedRangesJson': JSON.stringify(playedRanges), 'state': this.getPlayingState(), 'width': width, }; }); } } /** * @param {!AmpElement} video * @return {boolean} * @restricted */ function supportsFullscreenViaApi(video) { // TODO(alanorozco): Determine this via a flag in the component itself. return !!{ 'amp-dailymotion': true, 'amp-ima-video': true, }[video.tagName.toLowerCase()]; } /** Manages rotate-to-fullscreen video. */ export class AutoFullscreenManager { /** * @param {!./ampdoc-impl.AmpDoc} ampdoc * @param {!VideoManager} manager */ constructor(ampdoc, manager) { /** @private @const {!VideoManager} */ this.manager_ = manager; /** @private @const {!./ampdoc-impl.AmpDoc} */ this.ampdoc_ = ampdoc; /** @private {?../video-interface.VideoOrBaseElementDef} */ this.currentlyInFullscreen_ = null; /** @private {?../video-interface.VideoOrBaseElementDef} */ this.currentlyCentered_ = null; /** @private @const {!Array<!../video-interface.VideoOrBaseElementDef>} */ this.entries_ = []; /** * Unlisteners for global objects * @private {!Array<!UnlistenDef>} */ this.unlisteners_ = []; // eslint-disable-next-line jsdoc/require-returns /** @private @const {function()} */ this.boundSelectBestCentered_ = () => this.selectBestCenteredInPortrait_(); /** * @param {!../video-interface.VideoOrBaseElementDef} video * @return {boolean} */ this.boundIncludeOnlyPlaying_ = (video) => this.getPlayingState_(video) == PlayingStates_Enum.PLAYING_MANUAL; /** * @param {!IntersectionObserverEntry} a * @param {!IntersectionObserverEntry} b * @return {number} */ this.boundCompareEntries_ = (a, b) => this.compareEntries_(a, b); this.installOrientationObserver_(); this.installFullscreenListener_(); } /** @public */ dispose() { this.unlisteners_.forEach((unlisten) => unlisten()); this.unlisteners_.length = 0; } /** @param {!VideoEntry} entry */ register(entry) { const {video} = entry; const {element} = video; if (!this.canFullscreen_(element)) { return; } this.entries_.push(video); listen(element, VideoEvents_Enum.PAUSE, this.boundSelectBestCentered_); listen(element, VideoEvents_Enum.PLAYING, this.boundSelectBestCentered_); listen(element, VideoEvents_Enum.ENDED, this.boundSelectBestCentered_); video .signals() .whenSignal(VideoServiceSignals_Enum.USER_INTERACTED) .then(this.boundSelectBestCentered_); // Set always this.selectBestCenteredInPortrait_(); } /** @private */ installFullscreenListener_() { const root = this.ampdoc_.getRootNode(); const exitHandler = () => this.onFullscreenExit_(); this.unlisteners_.push( listen(root, 'webkitfullscreenchange', exitHandler), listen(root, 'mozfullscreenchange', exitHandler), listen(root, 'fullscreenchange', exitHandler), listen(root, 'MSFullscreenChange', exitHandler) ); } /** * @return {boolean} * @visibleForTesting */ isInLandscape() { return isLandscape(this.ampdoc_.win); } /** * @param {!AmpElement} video * @return {boolean} * @private */ canFullscreen_(video) { // Safari and iOS can only fullscreen <video> elements directly. In cases // where the player component is implemented via an <iframe>, we need to // rely on a postMessage API to fullscreen. Such an API is not necessarily // provided by every player. const internalElement = getInternalVideoElementFor(video); if (internalElement.tagName.toLowerCase() == 'video') { return true; } const platform = Services.platformFor(this.ampdoc_.win); if (!(platform.isIos() || platform.isSafari())) { return true; } return supportsFullscreenViaApi(video); } /** @private */ onFullscreenExit_() { this.currentlyInFullscreen_ = null; } /** @private */ installOrientationObserver_() { // TODO(alanorozco) Update based on support const {win} = this.ampdoc_; const {screen} = win; // Chrome considers 'orientationchange' to be an untrusted event, but // 'change' on screen.orientation is considered a user interaction. // We still need to listen to 'orientationchange' on Chrome in order to // exit fullscreen since 'change' does not fire in this case. if (screen && 'orientation' in screen) { const orient = /** @type {!ScreenOrientation} */ (screen.orientation); this.unlisteners_.push( listen(orient, 'change', () => this.onRotation_()) ); } // iOS Safari does not have screen.orientation but classifies // 'orientationchange' as a user interaction. this.unlisteners_.push( listen(win, 'orientationchange', () => this.onRotation_()) ); } /** @private */ onRotation_() { if (this.isInLandscape()) { if (this.currentlyCentered_ != null) { this.enter_(this.currentlyCentered_); } return; } if (this.currentlyInFullscreen_) { this.exit_(this.currentlyInFullscreen_); } } /** * @param {!../video-interface.VideoOrBaseElementDef} video * @private */ enter_(video) { const platform = Services.platformFor(this.ampdoc_.win); this.currentlyInFullscreen_ = video; if (platform.isAndroid() && platform.isChrome()) { // Chrome on Android somehow knows what we're doing and executes a nice // transition by default. Delegating to browser. video.fullscreenEnter(); return; } this.scrollIntoIfNotVisible_(video).then(() => video.fullscreenEnter()); } /** * @param {!../video-interface.VideoOrBaseElementDef} video * @private */ exit_(video) { this.currentlyInFullscreen_ = null; this.scrollIntoIfNotVisible_(video, 'center').then(() => video.fullscreenExit() ); } /** * Scrolls to a video if it's not in view. * @param {!../video-interface.VideoOrBaseElementDef} video * @param {?string=} optPos * @return {!Promise} * @private */ scrollIntoIfNotVisible_(video, optPos = null) { const {element} = video; const viewport = this.getViewport_(); return this.onceOrientationChanges_() .then(() => measureIntersection(element)) .then(({boundingClientRect}) => { const {bottom, top} = boundingClientRect; const vh = viewport.getSize().height; const fullyVisible = top >= 0 && bottom <= vh; if (fullyVisible) { return Promise.resolve(); } const pos = optPos ? dev().assertString(optPos) : bottom > vh ? 'bottom' : 'top'; return viewport.animateScrollIntoView(element, pos); }); } /** * @private * @return {./viewport/viewport-interface.ViewportInterface} */ getViewport_() { return Services.viewportForDoc(this.ampdoc_); } /** * @private * @return {!Promise} */ onceOrientationChanges_() { const magicNumber = 330; return Services.timerFor(this.ampdoc_.win).promise(magicNumber); } /** * @private * @return {!Promise<?../video-interface.VideoOrBaseElementDef>} */ selectBestCenteredInPortrait_() { if (this.isInLandscape()) { return Promise.resolve(this.currentlyCentered_); } this.currentlyCentered_ = null; const intersectionsPromise = this.entries_ .filter(this.boundIncludeOnlyPlaying_) .map((e) => measureIntersection(e.element)); return Promise.all(intersectionsPromise).then((intersections) => { const selected = intersections.sort(this.boundCompareEntries_)[0]; if ( selected && selected.intersectionRatio > MIN_VISIBILITY_RATIO_FOR_AUTOPLAY ) { return selected.target .getImpl() .then((video) => (this.currentlyCentered_ = video)); } return this.currentlyCentered_; }); } /** * Compares two videos in order to sort them by "best centered". * @param {!IntersectionObserverEntry} a * @param {!IntersectionObserverEntry} b * @return {number} */ compareEntries_(a, b) { const {boundingClientRect: rectA, intersectionRatio: ratioA} = a; const {boundingClientRect: rectB, intersectionRatio: ratioB} = b; // Prioritize by how visible they are, with a tolerance of 10% const ratioTolerance = 0.1; const ratioDelta = ratioA - ratioB; if (Math.abs(ratioDelta) > ratioTolerance) { return ratioDelta; } // Prioritize by distance from center. const viewport = Services.viewportForDoc(this.ampdoc_); const centerA = centerDist(viewport, rectA); const centerB = centerDist(viewport, rectB); if (centerA < centerB || centerA > centerB) { return centerA - centerB; } // Everything else failing, choose the highest element. return rectA.top - rectB.top; } /** * @param {!../video-interface.VideoOrBaseElementDef} video * @return {!../video-interface.PlayingStateDef} * @private */ getPlayingState_(video) { return this.manager_.getPlayingState( /** @type {!../video-interface.VideoInterface} */ (video) ); } } /** * @param {!./viewport/viewport-interface.ViewportInterface} viewport * @param {{top: number, height: number}} rect * @return {number} */ function centerDist(viewport, rect) { const centerY = rect.top + rect.height / 2; const centerViewport = viewport.getSize().height / 2; return Math.abs(centerY - centerViewport); } /** * @param {!Window} win * @return {boolean} */ function isLandscape(win) { if (win.screen && 'orientation' in win.screen) { return win.screen.orientation.type.startsWith('landscape'); } return Math.abs(win.orientation) == 90; } /** @visibleForTesting */ export const PERCENTAGE_INTERVAL = 5; /** @visibleForTesting */ export const PERCENTAGE_FREQUENCY_WHEN_PAUSED_MS = 500; /** @private */ const PERCENTAGE_FREQUENCY_MIN_MS = 250; /** @private */ const PERCENTAGE_FREQUENCY_MAX_MS = 4000; /** * Calculates the "ideal" analytics check frequency from playback start, e.g. * the amount of ms after each PERCENTAGE_INTERVAL. * @param {number} durationSeconds * @return {number} */ function calculateIdealPercentageFrequencyMs(durationSeconds) { return durationSeconds * 10 * PERCENTAGE_INTERVAL; } /** * Calculates the "actual" analytics check frequency by calculating the ideal * frequency and clamping it between MIN and MAX. * @param {number} durationSeconds * @return {number} */ function calculateActualPercentageFrequencyMs(durationSeconds) { return clamp( calculateIdealPercentageFrequencyMs(durationSeconds), PERCENTAGE_FREQUENCY_MIN_MS, PERCENTAGE_FREQUENCY_MAX_MS ); } /** * Handle cases such as livestreams or videos with no duration information is * available, where 1 second is the default duration for some video players. * @param {?number=} duration * @return {boolean} */ const isDurationFiniteNonZero = (duration) => !!duration && !isNaN(duration) && duration > 1; /** @visibleForTesting */ export class AnalyticsPercentageTracker { /** * @param {!Window} win * @param {!VideoEntry} entry */ constructor(win, entry) { // This is destructured in `calculate_()`, but the linter thinks it's unused /** @private @const {!./timer-impl.Timer} */ this.timer_ = Services.timerFor(win); /** @private @const {!VideoEntry} */ this.entry_ = entry; /** @private {?Array<!UnlistenDef>} */ this.unlisteners_ = null; /** @private {number} */ this.last_ = 0; /** * Counter for each trigger `start`. This is to prevent duplicate events if * two consecutive triggers take place, or to prevent events firing once * the tracker is stopped. * @private {number} */ this.triggerId_ = 0; } /** @public */ start() { const {element} = this.entry_.video; this.stop(); this.unlisteners_ = this.unlisteners_ || []; // If the video has already emitted LOADEDMETADATA, the event below // will never fire, so we check if it's already available here. if (this.hasDuration_()) { this.calculate_(this.triggerId_); } else { this.unlisteners_.push( listenOnce(element, VideoEvents_Enum.LOADEDMETADATA, () => { if (this.hasDuration_()) { this.calculate_(this.triggerId_); } }) ); } this.unlisteners_.push( listen(element, VideoEvents_Enum.ENDED, () => { if (this.hasDuration_()) { this.maybeTrigger_(/* normalizedPercentage */ 100); } }) ); } /** @public */ stop() { if (!this.unlisteners_) { return; } while (this.unlisteners_.length > 0) { this.unlisteners_.pop()(); } this.triggerId_++; } /** * @return {boolean} * @private */ hasDuration_() { const {video} = this.entry_; const duration = video.getDuration(); if (!isDurationFiniteNonZero(duration)) { return false; } if ( calculateIdealPercentageFrequencyMs(duration) < PERCENTAGE_FREQUENCY_MIN_MS ) { const bestResultLength = Math.ceil( (PERCENTAGE_FREQUENCY_MIN_MS * (100 / PERCENTAGE_INTERVAL)) / 1000 ); this.warnForTesting_( 'This video is too short for `video-percentage-played`. ' + 'Reports may be inaccurate. For best results, use videos over', bestResultLength, 'seconds long.', video.element ); } return true; } /** * @param {...*} args * @private */ warnForTesting_(...args) { user().warn.apply(user(), [TAG].concat(args)); } /** * @param {number=} triggerId * @private */ calculate_(triggerId) { if (triggerId != this.triggerId_) { return; } const {entry_: entry, timer_: timer} = this; const {video} = entry; const calculateAgain = () => this.calculate_(triggerId); if (entry.getPlayingState() == PlayingStates_Enum.PAUSED) { timer.delay(calculateAgain, PERCENTAGE_FREQUENCY_WHEN_PAUSED_MS); return; } const duration = video.getDuration(); // TODO(#25954): Further investigate root cause and remove this protection // if appropriate. if (!isDurationFiniteNonZero(duration)) { timer.delay(calculateAgain, PERCENTAGE_FREQUENCY_WHEN_PAUSED_MS); return; } const frequencyMs = calculateActualPercentageFrequencyMs(duration); const percentage = (video.getCurrentTime() / duration) * 100; const normalizedPercentage = Math.floor(percentage / PERCENTAGE_INTERVAL) * PERCENTAGE_INTERVAL; devAssert(isFiniteNumber(normalizedPercentage)); this.maybeTrigger_(normalizedPercentage); timer.delay(calculateAgain, frequencyMs); } /** * @param {number} normalizedPercentage * @private */ maybeTrigger_(normalizedPercentage) { if (normalizedPercentage <= 0) { return; } if (this.last_ == normalizedPercentage) { return; } this.last_ = normalizedPercentage; this.analyticsEventForTesting_(normalizedPercentage); } /** * @param {number} normalizedPercentage * @private */ analyticsEventForTesting_(normalizedPercentage) { analyticsEvent(this.entry_, VideoAnalyticsEvents_Enum.PERCENTAGE_PLAYED, { 'normalizedPercentage': normalizedPercentage.toString(), }); } } /** * @param {!VideoEntry} entry * @param {!VideoAnalyticsEvents_Enum} eventType * @param {!{[key: string]: string}=} opt_vars A map of vars and their values. * @private */ function analyticsEvent(entry, eventType, opt_vars) { const {video} = entry; entry.getAnalyticsDetails().then((details) => { if (opt_vars) { Object.assign(details, opt_vars); } dispatchCustomEvent(video.element, eventType, details); }); } /** @param {!Node|!./ampdoc-impl.AmpDoc} nodeOrDoc */ export function installVideoManagerForDoc(nodeOrDoc) { registerServiceBuilderForDoc(nodeOrDoc, 'video-manager', VideoManager); }