diff --git a/extensions/amp-3q-player/0.1/amp-3q-player.js b/extensions/amp-3q-player/0.1/amp-3q-player.js index c25cf71c8056..b795ac9a512c 100644 --- a/extensions/amp-3q-player/0.1/amp-3q-player.js +++ b/extensions/amp-3q-player/0.1/amp-3q-player.js @@ -248,6 +248,16 @@ class Amp3QPlayer extends AMP.BaseElement { return isFullscreenElement(dev().assertElement(this.iframe_)); } + /** @override */ + getMetadata() { + // Not implemented + } + + /** @override */ + preimplementsMediaSessionAPI() { + return false; + } + /** @override */ getCurrentTime() { // Not supported. diff --git a/extensions/amp-brid-player/0.1/amp-brid-player.js b/extensions/amp-brid-player/0.1/amp-brid-player.js index e72d24faf76f..5b48eeced6f6 100644 --- a/extensions/amp-brid-player/0.1/amp-brid-player.js +++ b/extensions/amp-brid-player/0.1/amp-brid-player.js @@ -319,6 +319,16 @@ class AmpBridPlayer extends AMP.BaseElement { return isFullscreenElement(dev().assertElement(this.iframe_)); } + /** @override */ + getMetadata() { + // Not implemented + } + + /** @override */ + preimplementsMediaSessionAPI() { + return false; + } + /** @override */ getCurrentTime() { // Not supported. diff --git a/extensions/amp-dailymotion/0.1/amp-dailymotion.js b/extensions/amp-dailymotion/0.1/amp-dailymotion.js index 8d95e77d8784..5f7469b4f00d 100644 --- a/extensions/amp-dailymotion/0.1/amp-dailymotion.js +++ b/extensions/amp-dailymotion/0.1/amp-dailymotion.js @@ -392,6 +392,16 @@ class AmpDailymotion extends AMP.BaseElement { } } + /** @override */ + getMetadata() { + // Not implemented + } + + /** @override */ + preimplementsMediaSessionAPI() { + return false; + } + /** @override */ getCurrentTime() { // Not supported. diff --git a/extensions/amp-ima-video/0.1/amp-ima-video.js b/extensions/amp-ima-video/0.1/amp-ima-video.js index ebb9efc8ac1e..0012bab67f00 100644 --- a/extensions/amp-ima-video/0.1/amp-ima-video.js +++ b/extensions/amp-ima-video/0.1/amp-ima-video.js @@ -310,6 +310,16 @@ class AmpImaVideo extends AMP.BaseElement { return isFullscreenElement(dev().assertElement(this.iframe_)); } + /** @override */ + getMetadata() { + // Not implemented + } + + /** @override */ + preimplementsMediaSessionAPI() { + return false; + } + /** @override */ getCurrentTime() { // Not supported. diff --git a/extensions/amp-nexxtv-player/0.1/amp-nexxtv-player.js b/extensions/amp-nexxtv-player/0.1/amp-nexxtv-player.js index dfd6291eb8f2..046e935f4ab5 100644 --- a/extensions/amp-nexxtv-player/0.1/amp-nexxtv-player.js +++ b/extensions/amp-nexxtv-player/0.1/amp-nexxtv-player.js @@ -274,6 +274,16 @@ class AmpNexxtvPlayer extends AMP.BaseElement { return isFullscreenElement(dev().assertElement(this.iframe_)); } + /** @override */ + getMetadata() { + // Not implemented + } + + /** @override */ + preimplementsMediaSessionAPI() { + return false; + } + /** @override */ getCurrentTime() { // Not supported. diff --git a/extensions/amp-ooyala-player/0.1/amp-ooyala-player.js b/extensions/amp-ooyala-player/0.1/amp-ooyala-player.js index 068793262806..9cc160dedb47 100644 --- a/extensions/amp-ooyala-player/0.1/amp-ooyala-player.js +++ b/extensions/amp-ooyala-player/0.1/amp-ooyala-player.js @@ -259,6 +259,16 @@ class AmpOoyalaPlayer extends AMP.BaseElement { return isFullscreenElement(dev().assertElement(this.iframe_)); } + /** @override */ + getMetadata() { + // Not implemented + } + + /** @override */ + preimplementsMediaSessionAPI() { + return false; + } + /** @override */ getCurrentTime() { // Not supported. diff --git a/extensions/amp-video/0.1/amp-video.js b/extensions/amp-video/0.1/amp-video.js index 901d13177ce4..09401a31ecf4 100644 --- a/extensions/amp-video/0.1/amp-video.js +++ b/extensions/amp-video/0.1/amp-video.js @@ -59,23 +59,23 @@ const ATTRS_TO_PROPAGATE = */ class AmpVideo extends AMP.BaseElement { - /** - * @param {!AmpElement} element - */ + /** + * @param {!AmpElement} element + */ constructor(element) { super(element); - /** @private {?Element} */ + /** @private {?Element} */ this.video_ = null; - /** @private {?boolean} */ + /** @private {?boolean} */ this.muted_ = false; } - /** - * @param {boolean=} opt_onLayout - * @override - */ + /** + * @param {boolean=} opt_onLayout + * @override + */ preconnectCallback(opt_onLayout) { const videoSrc = this.getVideoSource_(); if (videoSrc) { @@ -84,10 +84,10 @@ class AmpVideo extends AMP.BaseElement { } } - /** - * @private - * @return {string} - */ + /** + * @private + * @return {string} + */ getVideoSource_() { let videoSrc = this.element.getAttribute('src'); if (!videoSrc) { @@ -99,12 +99,12 @@ class AmpVideo extends AMP.BaseElement { return videoSrc; } - /** @override */ + /** @override */ isLayoutSupported(layout) { return isLayoutSizeDefined(layout); } - /** @override */ + /** @override */ buildCallback() { this.video_ = this.element.ownerDocument.createElement('video'); @@ -114,10 +114,10 @@ class AmpVideo extends AMP.BaseElement { 'No "poster" attribute has been provided for amp-video.'); } - // Enable inline play for iOS. + // Enable inline play for iOS. this.video_.setAttribute('playsinline', ''); this.video_.setAttribute('webkit-playsinline', ''); - // Disable video preload in prerender mode. + // Disable video preload in prerender mode. this.video_.setAttribute('preload', 'none'); this.propagateAttributes(ATTRS_TO_PROPAGATE_ON_BUILD, this.video_, /* opt_removeMissingAttrs */ true); @@ -129,7 +129,7 @@ class AmpVideo extends AMP.BaseElement { Services.videoManagerForDoc(this.element).register(this); } - /** @override */ + /** @override */ mutatedAttributesCallback(mutations) { if (!this.video_) { return; @@ -148,12 +148,12 @@ class AmpVideo extends AMP.BaseElement { } } - /** @override */ + /** @override */ viewportCallback(visible) { this.element.dispatchCustomEvent(VideoEvents.VISIBILITY, {visible}); } - /** @override */ + /** @override */ layoutCallback() { this.video_ = dev().assertElement(this.video_); @@ -170,7 +170,7 @@ class AmpVideo extends AMP.BaseElement { /* opt_removeMissingAttrs */ true); this.getRealChildNodes().forEach(child => { - // Skip the video we already added to the element. + // Skip the video we already added to the element. if (this.video_ === child) { return; } @@ -181,7 +181,7 @@ class AmpVideo extends AMP.BaseElement { this.video_.appendChild(child); }); - // loadPromise for media elements listens to `loadstart` + // loadPromise for media elements listens to `loadstart` return this.loadPromise(this.video_).then(() => { this.element.dispatchCustomEvent(VideoEvents.LOAD); }); @@ -241,11 +241,11 @@ class AmpVideo extends AMP.BaseElement { if (ret && ret.catch) { ret.catch(() => { - // Empty catch to prevent useless unhandled promise rejection logging. - // Play can fail for many reasons such as video getting paused before - // play() is finished. - // We use events to know the state of the video and do not care about - // the success or failure of the play()'s returned promise. + // Empty catch to prevent useless unhandled promise rejection logging. + // Play can fail for many reasons such as video getting paused before + // play() is finished. + // We use events to know the state of the video and do not care about + // the success or failure of the play()'s returned promise. }); } } @@ -304,6 +304,26 @@ class AmpVideo extends AMP.BaseElement { return isFullscreenElement(dev().assertElement(this.video_)); } + /** @override */ + getMetadata() { + const poster = this.element.getAttribute('poster'); + if (poster) { + return { + 'title': '', + 'artist': '', + 'album': '', + 'artwork': [ + {'src': poster}, + ], + }; + } + } + + /** @override */ + preimplementsMediaSessionAPI() { + return false; + } + /** @override */ getCurrentTime() { return this.video_.currentTime; diff --git a/extensions/amp-youtube/0.1/amp-youtube.js b/extensions/amp-youtube/0.1/amp-youtube.js index 124906596d9e..16a67d0947dd 100644 --- a/extensions/amp-youtube/0.1/amp-youtube.js +++ b/extensions/amp-youtube/0.1/amp-youtube.js @@ -466,6 +466,18 @@ class AmpYoutube extends AMP.BaseElement { return isFullscreenElement(dev().assertElement(this.iframe_)); } + /** @override */ + getMetadata() { + // Not implemented + } + + /** @override */ + preimplementsMediaSessionAPI() { + // Youtube already updates the Media Session so no need for the video + // manager to update it too + return true; + } + /** @override */ getCurrentTime() { // Not supported. diff --git a/src/mediasession-helper.js b/src/mediasession-helper.js new file mode 100644 index 000000000000..d13f52f176d4 --- /dev/null +++ b/src/mediasession-helper.js @@ -0,0 +1,124 @@ +/** + * Copyright 2017 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {tryParseJson} from './json'; + +/** @const {./video-interface.VideoMetaDef} Dummy metadata used to fix a bug */ +export const EMPTY_METADATA = { + 'title': '', + 'artist': '', + 'album': '', + 'artwork': [ + {'src': ''}, + ], +}; + +/** + * Updates the Media Session API's metadata + * @param {!./service/ampdoc-impl.AmpDoc} ampdoc + * @param {!./video-interface.VideoMetaDef} metadata + * @param {function()=} playHandler + * @param {function()=} pauseHandler + */ +export function setMediaSession(ampdoc, + metadata, + playHandler, + pauseHandler) { + const win = ampdoc.win; + const navigator = win.navigator; + if ('mediaSession' in navigator && win.MediaMetadata) { + // Clear mediaSession (required to fix a bug when switching between two + // videos) + navigator.mediaSession.metadata = new win.MediaMetadata(EMPTY_METADATA); + // Add metadata + navigator.mediaSession.metadata = new win.MediaMetadata(metadata); + + navigator.mediaSession.setActionHandler('play', playHandler); + navigator.mediaSession.setActionHandler('pause', pauseHandler); + + // TODO(@wassgha) Implement seek & next/previous + } +} + + +/** + * Parses the schema.org json-ld formatted meta-data, looks for the page's + * featured image and returns it + * @param {!./service/ampdoc-impl.AmpDoc} ampdoc + * @return {string|undefined} + */ +export function parseSchemaImage(ampdoc) { + const doc = ampdoc.win.document; + const schema = doc.querySelector('script[type="application/ld+json"]'); + if (!schema) { + // No schema element found + return; + } + const schemaJson = tryParseJson(schema.textContent); + if (!schemaJson || !schemaJson['image']) { + // No image found in the schema + return; + } + + // Image definition in schema could be one of : + if (typeof schemaJson['image'] === 'string') { + // 1. "image": "http://..", + return schemaJson['image']; + } else if (schemaJson['image']['@list'] + && typeof schemaJson['image']['@list'][0] === 'string') { + // 2. "image": {.., "@list": ["http://.."], ..} + return schemaJson['image']['@list'][0]; + } else if (typeof schemaJson['image']['url'] === 'string') { + // 3. "image": {.., "url": "http://..", ..} + return schemaJson['image']['url']; + } else if (typeof schemaJson['image'][0] === 'string') { + // 4. "image": ["http://.. "] + return schemaJson['image'][0]; + } else { + return; + } +}; + +/** + * Parses the og:image if it exists and returns it + * @param {!./service/ampdoc-impl.AmpDoc} ampdoc + * @return {string|undefined} + */ +export function parseOgImage(ampdoc) { + const doc = ampdoc.win.document; + const metaTag = doc.querySelector('meta[property="og:image"]'); + if (metaTag) { + return metaTag.getAttribute('content'); + } else { + return; + } +}; + +/** + * Parses the website's Favicon and returns it + * @param {!./service/ampdoc-impl.AmpDoc} ampdoc + * @return {string|undefined} + */ +export function parseFavicon(ampdoc) { + const doc = ampdoc.win.document; + const linkTag = doc.querySelector('link[rel="shortcut icon"]') + || doc.querySelector('link[rel="icon"]'); + if (linkTag) { + return linkTag.getAttribute('href'); + } else { + return; + } +} diff --git a/src/service/video-manager-impl.js b/src/service/video-manager-impl.js index 988f5410a9f0..869c08128a05 100644 --- a/src/service/video-manager-impl.js +++ b/src/service/video-manager-impl.js @@ -38,11 +38,18 @@ import { PositionObserverFidelity, PositionInViewportEntryDef, } from './position-observer-impl'; +import {map} from '../utils/object'; import {layoutRectLtwh, RelativePositions} from '../layout-rect'; +import { + EMPTY_METADATA, + parseSchemaImage, + parseOgImage, + parseFavicon, + setMediaSession, +} from '../mediasession-helper'; import {Animation} from '../animation'; import * as st from '../style'; import * as tr from '../transition'; - /** * @const {number} Percentage of the video that should be in viewport before it * is considered visible. @@ -456,6 +463,8 @@ class VideoEntry { const element = dev().assert(video.element); + // Autoplay Variables + /** @private {boolean} */ this.userInteractedWithAutoPlay_ = false; @@ -538,10 +547,13 @@ class VideoEntry { this.hasFullscreenOnLandscape = fsOnLandscapeAttr == '' || fsOnLandscapeAttr == 'always'; - listenOncePromise(element, VideoEvents.LOAD) - .then(() => this.videoLoaded()); + // Media Session API Variables + /** @private {!../video-interface.VideoMetaDef} */ + this.metadata_ = EMPTY_METADATA; + listenOncePromise(element, VideoEvents.LOAD) + .then(() => this.videoLoaded()); listen(element, VideoEvents.PAUSE, () => this.videoPaused_()); listen(element, VideoEvents.PLAYING, () => this.videoPlayed_()); listen(element, VideoEvents.MUTED, () => this.muted_ = true); @@ -571,6 +583,23 @@ class VideoEntry { */ videoPlayed_() { this.isPlaying_ = true; + + if (!this.video.preimplementsMediaSessionAPI()) { + const playHandler = () => { + this.video.play(/*isAutoplay*/ false); + }; + const pauseHandler = () => { + this.video.pause(); + }; + // Update the media session + setMediaSession( + this.ampdoc_, + this.metadata_, + playHandler, + pauseHandler + ); + } + this.actionSessionManager_.beginSession(); if (this.isVisible_) { this.visibilitySessionManager_.beginSession(); @@ -617,6 +646,8 @@ class VideoEntry { this.inlineVidRect_ = this.video.element./*OK*/getBoundingClientRect(); }); + this.fillMediaSessionMetadata_(); + this.updateVisibility(); if (this.isVisible_) { // Handles the case when the video becomes visible before loading @@ -624,6 +655,44 @@ class VideoEntry { } } + /** + * Gets the provided metadata and fills in missing fields + * @private + */ + fillMediaSessionMetadata_() { + if (this.video.preimplementsMediaSessionAPI()) { + return; + } + + if (this.video.getMetadata()) { + const mapped = map(this.video.getMetadata()); + this.metadata_ = /** @type {!../video-interface.VideoMetaDef} */ (mapped); + } + + if (!this.metadata_.artwork || this.metadata_.artwork.length == 0) { + const posterUrl = parseSchemaImage(this.ampdoc_) + || parseOgImage(this.ampdoc_) + || parseFavicon(this.ampdoc_); + + 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') + || this.ampdoc_.win.document.title; + if (title) { + this.metadata_.title = title; + } + } + } + /** * Called when visibility of a video changes. * @private diff --git a/src/video-interface.js b/src/video-interface.js index b5952805bafa..ee0b0e97ee44 100644 --- a/src/video-interface.js +++ b/src/video-interface.js @@ -16,6 +16,16 @@ import {ActionTrust} from './action-trust'; /* eslint no-unused-vars: 0 */ +/** + * @typedef {{ + * artwork: Array, + * title: string, + * album: string, + * artist: string, + * }} + */ +export let VideoMetaDef; + /** * VideoInterface defines a common video API which any AMP component that plays * videos is expected to implement. @@ -104,6 +114,28 @@ export class VideoInterface { */ hideControls() {} + /** + * Returns video's meta data (artwork, title, artist, album, etc.) for use + * with the Media Session API + * artwork (Array): URL to the poster image (preferably a 512x512 PNG) + * title (string): Name of the video + * artist (string): Name of the video's author/artist + * album (string): Name of the video's album if it exists + * @return {!VideoMetaDef|undefined} metadata + */ + getMetadata() {} + + /** + * If this returns true then it will be assumed that the player implements + * the MediaSession API internally so that the video manager does not override + * it. If not, the video manager will use the metadata variable as well as + * inferred meta-data to update the video's Media Session notification. + * + * @return {boolean} + */ + preimplementsMediaSessionAPI() {} + + /** * Automatically comes from {@link ./base-element.BaseElement} * diff --git a/test/functional/test-mediasession-helper.js b/test/functional/test-mediasession-helper.js new file mode 100644 index 000000000000..f1ce57b832d0 --- /dev/null +++ b/test/functional/test-mediasession-helper.js @@ -0,0 +1,138 @@ +/** + * Copyright 2017 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + setMediaSession, + parseSchemaImage, + parseOgImage, + parseFavicon, +} from '../../src/mediasession-helper'; + +const schemaTemplate = ` +{ + "@context": "http://schema.org", + "@type": "NewsArticle", + "mainEntityOfPage": "something", + "headline": "Something Happened", + "datePublished": "Fri Jul 28 12:45:00 EDT 2017", + "dateModified": "Fri Jul 28 12:45:00 EDT 2017", + "description": "Appearantly, yesterday something happened", + "author": { + "@type": "Person", + "name": "Awesome Author" + }, + "publisher": { + "@type": "Organization", + "name": "Aperture Science", + "logo": { + "@type": "ImageObject", + "url": "logo-url", + "width": 133, + "height": 60 + } + }, + "image": { + "@type": "ImageObject", + "url": "http://example.com/image.png", + "height": 392, + "width": 696 + } +} +`; + + +describes.sandboxed('MediaSessionAPI Helper Functions', {}, () => { + let ampdoc; + let favicon; + let schema; + let ogImage; + let head; + + beforeEach(() => { + head = document.querySelector('head'); + // Favicon + favicon = document.createElement('link'); + favicon.setAttribute('rel', 'icon'); + favicon.setAttribute('href', 'http://example.com/favicon.ico'); + head.appendChild(favicon); + // Schema + schema = document.createElement('script'); + schema.setAttribute('type', 'application/ld+json'); + schema.innerHTML = schemaTemplate; + head.appendChild(schema); + // og-image + ogImage = document.createElement('meta'); + ogImage.setAttribute('property', 'og:image'); + ogImage.setAttribute('content', 'http://example.com/og-image.png'); + head.appendChild(ogImage); + ampdoc = { + win: { + 'document': document, + 'navigator': { + 'mediaSession': { + 'metadata': { + 'artist': '', + 'album': '', + 'artwork': [], + 'title': '', + }, + 'setActionHandler': () => { + }, + }, + }, + 'MediaMetadata': Object, + }, + }; + }); + + afterEach(() => { + head.removeChild(favicon); + head.removeChild(schema); + head.removeChild(ogImage); + }); + + it('should parse the schema and find the image', () => { + expect(parseSchemaImage(ampdoc)).to.equal('http://example.com/image.png'); + }); + + it('should parse the og-image', () => { + expect(parseOgImage(ampdoc)).to.equal('http://example.com/og-image.png'); + }); + + it('should parse the favicon', () => { + expect(parseFavicon(ampdoc)).to.equal('http://example.com/favicon.ico'); + }); + + it('should set the media session', () => { + expect(ampdoc.win.navigator.mediaSession.metadata).to.deep.equal({ + 'artist': '', + 'album': '', + 'artwork': [], + 'title': '', + }); + const fakeMetaData = { + 'artist': 'Some artist', + 'album': 'Some album', + 'artwork': [ + 'http://example.com/image.png', + ], + 'title': 'Some title', + }; + setMediaSession(ampdoc, fakeMetaData); + const newMetaData = ampdoc.win.navigator.mediaSession.metadata; + expect(newMetaData).to.deep.equal(fakeMetaData); + }); +}); diff --git a/test/functional/test-video-manager.js b/test/functional/test-video-manager.js index f76caab15e37..bdb9de859b78 100644 --- a/test/functional/test-video-manager.js +++ b/test/functional/test-video-manager.js @@ -227,7 +227,6 @@ describes.fakeWin('VideoManager', { }); }); - beforeEach(() => { sandbox = sinon.sandbox.create(); klass = createFakeVideoPlayerClass(env.win); @@ -526,6 +525,16 @@ function createFakeVideoPlayerClass(win) { return this.currentTime_; } + /** @override */ + getMetadata() { + // Not supported + } + + /** @override */ + preimplementsMediaSessionAPI() { + return false; + } + /** @override */ getDuration() { return this.duration_;