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 (
+