diff --git a/extensions/amp-video/1.0/storybook/VideoIframe.js b/extensions/amp-video/1.0/storybook/VideoIframe.js new file mode 100644 index 000000000000..81f34fe84a99 --- /dev/null +++ b/extensions/amp-video/1.0/storybook/VideoIframe.js @@ -0,0 +1,108 @@ +/** + * Copyright 2020 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 * as Preact from '../../../../src/preact'; +import {VideoIframe} from '../video-iframe'; +import {VideoWrapper} from '../video-wrapper'; +import {boolean, text, withKnobs} from '@storybook/addon-knobs'; +import {createCustomEvent} from '../../../../src/event-helper'; +import {useCallback} from '../../../../src/preact'; +import {withA11y} from '@storybook/addon-a11y'; + +export default { + title: 'VideoIframe', + component: VideoIframe, + decorators: [withA11y, withKnobs], +}; + +const AmpVideoIframeLike = (props) => { + const onMessage = useCallback((e) => { + // Expect HTMLMediaElement events from document in `src` as + // `{event: 'playing'}` + // (video-iframe-integration-v0.js talks similarly to HTMLMediaElement, + // so amp-video-iframe samples already mostly work). + if (e.data?.event) { + e.currentTarget.dispatchEvent( + createCustomEvent(window, e.data.event, /* detail */ null, { + bubbles: true, + cancelable: true, + }) + ); + } + }, []); + + const makeMethodMessage = useCallback( + (method) => + JSON.stringify({ + // Like amp-video-iframe + 'event': 'method', + 'method': method.toLowerCase(), + }), + [] + ); + + return ( + + ); +}; + +export const UsingVideoIframe = () => { + const width = text('width', '640px'); + const height = text('height', '360px'); + + const ariaLabel = text('aria-label', 'Video Player'); + const autoplay = boolean('autoplay', true); + const controls = boolean('controls', true); + const mediasession = boolean('mediasession', true); + const noaudio = boolean('noaudio', false); + const loop = boolean('loop', false); + const poster = text( + 'poster', + 'https://amp.dev/static/samples/img/amp-video-iframe-sample-placeholder.jpg' + ); + + const artist = text('artist', ''); + const album = text('album', ''); + const artwork = text('artwork', ''); + const title = text('title', ''); + + const src = text( + 'src', + 'https://amp.dev/static/samples/files/amp-video-iframe-videojs.html' + ); + return ( + + ); +}; diff --git a/extensions/amp-video/1.0/test/test-video-iframe.js b/extensions/amp-video/1.0/test/test-video-iframe.js new file mode 100644 index 000000000000..d6a58cbf0f75 --- /dev/null +++ b/extensions/amp-video/1.0/test/test-video-iframe.js @@ -0,0 +1,190 @@ +/** + * Copyright 2020 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 * as Preact from '../../../../src/preact'; +import {VideoIframe} from '../video-iframe'; +import {mount} from 'enzyme'; + +function dispatchMessage(window, opt_event) { + const event = window.document.createEvent('Event'); + event.initEvent('message', /* bubbles */ true, /* cancelable */ true); + window.dispatchEvent(Object.assign(event, opt_event)); +} + +describes.sandboxed('VideoIframe Preact component', {}, (env) => { + beforeEach(() => {}); + + it('unmutes per lack of `muted` prop', async () => { + const makeMethodMessage = env.sandbox.spy(); + const videoIframe = mount( + , + {attachTo: document.body} + ); + + await videoIframe.find('iframe').invoke('onCanPlay')(); + + expect(makeMethodMessage.withArgs('unmute')).to.have.been.calledOnce; + }); + + it('mutes per `muted` prop', async () => { + const makeMethodMessage = env.sandbox.spy(); + const videoIframe = mount( + , + {attachTo: document.body} + ); + + await videoIframe.find('iframe').invoke('onCanPlay')(); + + expect(makeMethodMessage.withArgs('mute')).to.have.been.calledOnce; + }); + + it('hides controls per lack of `controls` prop', async () => { + const makeMethodMessage = env.sandbox.spy(); + const videoIframe = mount( + , + {attachTo: document.body} + ); + + await videoIframe.find('iframe').invoke('onCanPlay')(); + + expect(makeMethodMessage.withArgs('hideControls')).to.have.been.calledOnce; + }); + + it('shows controls per `controls` prop', async () => { + const makeMethodMessage = env.sandbox.spy(); + const videoIframe = mount( + , + {attachTo: document.body} + ); + + await videoIframe.find('iframe').invoke('onCanPlay')(); + + expect(makeMethodMessage.withArgs('showControls')).to.have.been.calledOnce; + }); + + it('passes messages to onMessage', async () => { + const onMessage = env.sandbox.spy(); + const videoIframe = mount( + , + {attachTo: document.body} + ); + + const iframe = videoIframe.getDOMNode(); + + const data = {foo: 'bar'}; + dispatchMessage(window, {source: iframe.contentWindow, data}); + + expect( + onMessage.withArgs( + env.sandbox.match({ + currentTarget: iframe, + target: iframe, + data, + }) + ) + ).to.have.been.calledOnce; + }); + + it("ignores messages if source doesn't match iframe", async () => { + const onMessage = env.sandbox.spy(); + mount(, { + attachTo: document.body, + }); + dispatchMessage(window, {source: null, data: 'whatever'}); + expect(onMessage).to.not.have.been.called; + }); + + it('stops listening to messages on unmount', async () => { + const onMessage = env.sandbox.spy(); + const videoIframe = mount( + , + {attachTo: document.body} + ); + const iframe = videoIframe.getDOMNode(); + videoIframe.unmount(); + dispatchMessage(window, {source: iframe.contentWindow, data: 'whatever'}); + expect(onMessage).to.not.have.been.called; + }); + + it('unlistens only when unmounted', async () => { + const addEventListener = env.sandbox.stub(window, 'addEventListener'); + const removeEventListener = env.sandbox.stub(window, 'removeEventListener'); + + const videoIframe = mount( + {}} />, + {attachTo: document.body} + ); + + expect(addEventListener.withArgs('message')).to.have.been.calledOnce; + expect(removeEventListener.withArgs('message')).to.not.have.been.called; + + videoIframe.setProps({ + onMessage: () => { + // An unstable onMessage prop should not cause unlisten + }, + }); + videoIframe.update(); + expect(removeEventListener.withArgs('message')).to.not.have.been.called; + + videoIframe.unmount(); + expect(removeEventListener.withArgs('message')).to.have.been.calledOnce; + }); + + describe('uses makeMethodMessage to posts imperative handle methods', () => { + ['play', 'pause'].forEach((method) => { + it(`with \`${method}\``, async () => { + let videoIframeRef; + + const makeMethodMessage = (method) => ({makeMethodMessageFor: method}); + + const makeMethodMessageSpy = env.sandbox.spy(makeMethodMessage); + + const videoIframe = mount( + (videoIframeRef = ref)} + src="about:blank" + makeMethodMessage={makeMethodMessageSpy} + />, + {attachTo: document.body} + ); + + const postMessage = env.sandbox.stub( + videoIframe.getDOMNode().contentWindow, + 'postMessage' + ); + + videoIframeRef[method](); + await videoIframe.find('iframe').invoke('onCanPlay')(); + + expect(makeMethodMessageSpy.withArgs(method)).to.have.been.calledOnce; + expect( + postMessage.withArgs( + env.sandbox.match(makeMethodMessage(method)), + '*' + ) + ).to.have.been.calledOnce; + }); + }); + }); +}); diff --git a/extensions/amp-video/1.0/video-iframe.js b/extensions/amp-video/1.0/video-iframe.js new file mode 100644 index 000000000000..b712c6e9f8bd --- /dev/null +++ b/extensions/amp-video/1.0/video-iframe.js @@ -0,0 +1,165 @@ +/** + * Copyright 2020 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 * as Preact from '../../../src/preact'; +import {Deferred} from '../../../src/utils/promise'; +import {forwardRef} from '../../../src/preact/compat'; +import { + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, +} from '../../../src/preact'; + +const DEFAULT_SANDBOX = [ + 'allow-scripts', + 'allow-same-origin', + 'allow-popups', + 'allow-popups-to-escape-sandbox', + 'allow-top-navigation-by-user-activation', +].join(' '); + +/** + * @param {T} prop + * @return {{current: ?T}} + * @template T + */ +function usePropRef(prop) { + const ref = useRef(null); + ref.current = prop; + return ref; +} + +/** + * Goes inside a VideoWrapper. + * + * import {VideoIframe} from '.../video-iframe'; + * import {VideoWrapper} from '.../video-wrapper'; + * render() + * + * Usable on the AMP layer through VideoBaseElement. + * + * @param {!VideoIframeDef.Props} props + * @param {{current: (T|null)}} ref + * @return {PreactDef.Renderable} + * @template T + */ +function VideoIframeWithRef( + { + loading = 'lazy', + sandbox = DEFAULT_SANDBOX, + muted = false, + controls = false, + origin, + onCanPlay, + onMessage, + makeMethodMessage, + ...rest + }, + ref +) { + const iframeRef = useRef(null); + + const readyDeferred = useMemo(() => new Deferred(), []); + + const postMethodMessage = useCallback( + (method) => { + if (!iframeRef.current || !iframeRef.current.contentWindow) { + return; + } + readyDeferred.promise.then(() => { + const message = makeMethodMessage(method); + iframeRef.current.contentWindow./*OK*/ postMessage(message, '*'); + }); + }, + [readyDeferred.promise, makeMethodMessage] + ); + + useImperativeHandle( + ref, + () => ({ + play: () => postMethodMessage('play'), + pause: () => postMethodMessage('pause'), + }), + [postMethodMessage] + ); + + // Keep `onMessage` in a ref to prevent re-listening on every render. + // This could otherwise occur when the passed `onMessage` is not memoized. + const onMessageRef = usePropRef(onMessage); + + useLayoutEffect(() => { + /** @param {Event} event */ + function handleMessage(event) { + if (!onMessageRef.current) { + return; + } + + if ( + (origin && !origin.test(event.origin)) || + event.source != iframeRef.current.contentWindow + ) { + return; + } + + // Triggers like an HTMLMediaElement, so we give it an iframe handle + // to dispatch events from. They're caught from being set on {...rest} so + // setting onPlay, etc. props should just work. + onMessageRef.current({ + // Event + currentTarget: iframeRef.current, + target: iframeRef.current, + + // MessageEvent + data: event.data, + }); + } + + const {defaultView} = iframeRef.current.ownerDocument; + defaultView.addEventListener('message', handleMessage); + return () => defaultView.removeEventListener('message', handleMessage); + }, [origin, onMessageRef]); + + useLayoutEffect(() => { + postMethodMessage(muted ? 'mute' : 'unmute'); + }, [muted, postMethodMessage]); + + useLayoutEffect(() => { + postMethodMessage(controls ? 'showControls' : 'hideControls'); + }, [controls, postMethodMessage]); + + return ( +