You should first read through the guide to Building a Bento AMP Component. Do not follow the steps to generate an extension, since they're specified here. Once you're familiar with the concepts related to AMP extensions and Bento components, follow this guide instead.
- How Video Player Components Work
- Getting Started
- Directory Structure
- Extend
VideoBaseElement
for AMP - Define a Preact component
- Completing your extension
- Example Pull Requests
AMP and Bento provide default video player capabilities in order to create a uniform experience. For example, videos only autoplay while they're visible and muted, and they consistently unmute when clicked. They send the same event signals to amp-analytics
, or may be pinned to a corner in the same way in combination with amp-video-docking
.
On a host document, player components must dispatch the same events and implement the same methods so that playback and user interface can be coordinated successfully.
Preact components can support this behavior by using a VideoWrapper
that renders a specified component
:
return <VideoWrapper component="video" {...props} />
The component
prop can be a string to specify a <video>
element or an id reference to a component whose interface is similar to HTMLMediaElement
.
However, most video players are embedded through an iframe so they should use VideoIframe
instead. This is a specialized VideoWrapper
that doesn't require an underlying component
:
return <VideoIframe {...props}>
To enable component support for AMP documents, our video player element must extend from a base class VideoBaseElement
. This enables actions and analytics, and allows us to define further behavior specific to the AMP layer, like parsing element attributes into Preact props.
This guide covers how to implement video player components that are internally implemented using these Preact and AMP components.
Start by generating an extension specifying --bento
and --nojss
. We name our extension amp-fantastic-player
, ending with -player
according to our guidelines for naming a third-party component.
amp make-extension --bento --nojss --name=amp-fantastic-player
A full directory for a Bento component is generated, but this guide will cover the following files in particular:
/extensions/amp-fantastic-player/1.0/
├── base-element.js # Preact base element
├── component.js # Preact implementation
├── amp-my-fantastic-player.js # Element's implementation
└── amp-my-fantastic-player.css # Custom CSS
Our BaseElement
should be a superclass of VideoBaseElement
. In base-element.js
, we change:
import {MyFantasticPlayer} from './component';
- import {PreactBaseElement} from '#preact/base-element';
+ import {VideoBaseElement} from '../../amp-video/1.0/video-base-element';
- export class BaseElement extends PreactBaseElement {}
+ export class BaseElement extends VideoBaseElement {}
into:
// base-element.js
// ...
import {MyFantasticPlayer} from './component';
import {VideoBaseElement} from '../../amp-video/1.0/base-element';
export class BaseElement extends VideoBaseElement {}
This enables support for AMP actions and analytics, once we map attributes to their prop counterparts in BaseElement['props']
, and we implement the Preact component.
props
map the AMP element's attributes to the Preact component props. Take a look at VideoBaseElement
for how most video properties are mapped. On your own base-element.js
, you should specify any of them you support.
// base-element.js
// ...
/** @override */
BaseElement['props'] = {
'autoplay': {attr: 'autoplay', type: 'boolean'},
};
If you need to directly insert nodes to the document, like a <video>
element, you need to use a <VideoWrapper>
.
However, it's more likely that you load a third-party iframe and you communicate with the host via postMessage
. In this case you should use a <VideoIframe>
as opposed to a <VideoWrapper>
.
⚠️ Components may not embed scripts from a third-party location into host documents. If a third-party script is absolutely required, like on<amp-ima-video>
, it must be inserted in an intermediate iframe, which we call a proxy frame.Proxy frames on Bento have not yet been tested as video player components, so they're not covered in this guide. If you wish to use one, please get in touch with
@alanorozco
via a Github issue or on on Slack in the#contributing
channel.
To enable AMP actions (my-element.play
) and the Preact component's imperative handle (myPlayerRef.current.play()
), you'll have to forwardRef
. Rename FantasticPlayer
to FantasticPlayerWithRef
, and export a FantasticPlayer
that forwards a ref
into the former.
+ import {forwardRef} from '#preact/compat';
- export function FantasticPlayer({...rest}) {
+ function FantasticPlayerWithRef({...rest}, ref) {
...
}
+ const FantasticPlayer = forwardRef(FantasticPlayerWithRef);
+ FantasticPlayer.displayName = 'FantasticPlayer'; // Make findable for tests.
+ export {FantasticPlayer};
So the outer structure looks like:
// component.js
// ...
import {forwardRef} from '#preact/compat';
// ...
function FantasticPlayerWithRef({...rest}, ref) {
// ...
}
//...
const FantasticPlayer = forwardRef(FantasticPlayerWithRef);
FantasticPlayer.displayName = 'FantasticPlayer'; // Make findable for tests.
export {FantasticPlayer};
Your FantasticPlayer
component should return a VideoIframe
that's configured to a corresponding postMessage
API. To start, we update the implementation in component.js
:
- import {ContainWrapper} from '#preact/component';
+ import {VideoIframe} from '../../amp-video/1.0/video-iframe';
function FantasticPlayerWithRef({...rest}, ref) {
- ...
+ const src = 'https://example.com/fantastic';
+ const makeMethodMessage = useCallback(() => '{}', []);
+ const onMessage = useCallback((e) => {
+ console.log(e);
+ }, []);
return (
- <ContainWrapper layout size paint {...rest} >
- ...
- </ContainWrapper>
+ <VideoIframe
+ ref={ref}
+ {...rest}
+ src={src}
+ makeMethodMessage={makeMethodMessage}
+ onMessage={onMessage}
+ />
);
}
So that our component returns a <VideoIframe>
:
// component.js
// ...
import {VideoIframe} from '../../amp-video/1.0/video-iframe';
// ...
function FantasticPlayerWithRef({...rest}, ref) {
const src = 'https://example.com/fantastic';
const makeMethodMessage = useCallback(() => '{}', []);
const onMessage = useCallback((e) => {
console.log(e);
}, []);
return (
<VideoIframe
ref={ref}
{...rest}
src={src}
makeMethodMessage={makeMethodMessage}
onMessage={onMessage}
/>
);
}
We're rendering an iframe that always loads https://example.com/fantastic
, but we'll specify a dynamic URL later. Likewise, we'll need to define implementations for the communication functions makeMethodMessage
and onMessage
.
You may use props to construct the src
, like using a videoId
to load https://example.com/fantastic/${videoId}/
.
We employ the useMemo()
hook so that the src
is generated only when the videoId
changes:
// component.js
// ...
function FantasticPlayerWithRef(
{videoId, ...rest},
ref
) {
// ...
const src = useMemo(
() =>
`https://example.com/fantastic/${encodeURIComponent(videoId)}/`,
[videoId]
);
// ...
return (
<VideoIframe
{...rest}
src={src}
...
/>
);
}
By default, messages from the iframe are only verified by comparing their contentWindow
. You should define a regular expression that verifies their origin:
// component.js
// ...
return (
<VideoIframe
{...rest}
origin={new RegExp('^https://((player|www)\.)?example\.com/?$')}
...
/>
);
We need the ability to tell the iframe to execute certain actions, for example play
, mute
or hideControls
. When using an iframe to load a player, this is done by sending a postMessage
downstream.
makeMethodMessage
takes an action to execute as a string
, and returns another string
corresponding to a message to send.
type MakeMethodMessageFunction = (method: string) => string;
We implement this function with useCallback()
so that it's created only when required as specified by hook dependencies. It's recommended that you also create a higher-level makeMessage
function that creates and serializes messages as your iframe's interface needs it. In this case, we JSON.stringify
a videoId
and a method
:
// component.js
// ...
function makeMessage(videoId, method) {
return JSON.stringify({
'videoId': videoId,
'method': method,
});
}
function FantasticPlayerWithRef(
{videoId, ...rest},
ref
) {
// ...
const makeMethodMessage = useCallback(
(method) => makeMessage(videoId, method),
[videoId]
);
// ...
return (
<VideoIframe
{...rest}
makeMethodMessage={makeMethodMessage}
...
/>
);
}
Upstream events originated by the iframe are received as messages. You should define a function that interprets these messages and dispatches HTMLMediaElement
events.
type OnMessageFunction = (event: MessageEvent) => void;
⚠️ This is an incompleteMessageEvent
. It's a copy that containscurrentTarget
(the originating<iframe>
),target
(same ascurrentTarget
) anddata
(the data sent by the iframe withpostMessage
). If you need to copy other properties, add them to the appropriate place invideo-iframe.js
and add@alanorozco
as a reviewer on your pull request.
Here we implement the canplay
, play
and pause
events for an iframe that posts them as the message {"event": "play"}
.
// component.js
// ...
function onMessage(event) {
const {data, currentTarget} = event;
switch (data?.event) {
case 'canplay':
case 'play':
case 'pause':
dispatchCustomEvent(currentTarget, data.event, null, {
bubbles: true,
cancelable: false,
});
break;
}
}
function FantasticPlayerWithRef(
{videoId, ...rest},
ref
) {
// ...
return (
<VideoIframe
{...rest}
onMessage={onMessage}
...
/>
);
}
Your iframe's interface to post messages is likely different, but your component should always dispatch HTMLMediaElement
events upstream.
All Bento components implemented using iframes should utilize placeholders to mitigate the risk of poor perceived performance. Placeholders, Fallbacks, and Loaders can be toggled on/off using the hooks provided to the Preact Component. See here for detailed instructions on how to toggle placeholders/fallbacks/loaders.
It is encouraged to create a Storybook story that specifically demonstrates utilization of placeholders and fallbacks.
If you need an iframe, you should ignore this section and use
VideoIframe
instead. It requires less work and likely provides all you need. Read on if you're sure that you need to write to a host document directly to create<video>
elements, or otherwise manage frames manually (like creating proxy frames).
Your FantasticPlayer
component should return a VideoWrapper
that's configured to a corresponding postMessage
API. To start, we update the implementation in component.js
.
- import {ContainWrapper} from '#preact/component';
+ import {VideoWrapper} from '../../amp-video/1.0/component';
function FantasticPlayerWithRef({...rest}, ref) {
- ...
return (
- <ContainWrapper layout size paint {...rest} >
- ...
- </ContainWrapper>
+ <VideoWrapper ref={ref} {...rest} component="video" />
);
}
So that our component returns a <VideoWrapper>
:
// component.js
// ...
import {VideoWrapper} from '../../amp-video/1.0/component';
// ...
function FantasticPlayerWithRef({...rest}, ref) {
return <VideoWrapper ref={ref} {...rest} component="video" />;
}
We're specifying "video"
as the element to render, which is also the default. We'll later change this into our own component implementation.
The VideoWrapper
must interact with an element that implements the HTMLMediaInterface
like <video>
, or a Preact component that emulates the same interface.
For example, we may set a component={FantasticPlayerInternal}
, where the specified function controls how we render and interact with the <video>
element.
By passing the ref
through, we're able to call methods like play()
from FantasticPlayer
on the <video>
element itself. By passing the ...rest
of the props we make sure that src
is set, in addition to listening to playback events through props like onPlay
.
// component.js
// ...
function FantasticPlayerInternalWithRef({sources, ...rest}, ref) {
return (
<div>
<video ref={ref} {...rest}>
{sources}
</video>
</div>
);
}
const FantasticPlayerInternal = forwardRef(FantasticPlayerInternalWithRef);
// ...
function FantasticPlayerWithRef({...rest}, ref) {
return (
<VideoWrapper
ref={ref}
{...rest}
component={FantasticPlayerInternal}
/>
);
}
In the previous example, props received from the VideoWrapper
are implicitly set through ...rest
. If we set each explicitly, we see the HTMLMediaInterface
attributes and events handled.
// component.js
// ...
function FantasticPlayerInternalWithRef(
{
muted,
loop,
controls,
onCanPlay,
onLoadedMetadata,
onPlaying,
onPause,
onEnded,
onError,
src,
poster,
style,
sources,
},
ref
) {
return (
<div>
<video
ref={ref}
muted={muted}
loop={loop}
controls={controls}
onCanPlay={onCanPlay}
onLoadedMetadata={onLoadedMetadata}
onPlaying={onPlaying}
onPause={onPause}
onEnded={onEnded}
onError={onError}
src={src}
poster={poster}
style={style}
>
{sources}
</video>
</div>
);
}
We can wrap playback events set on these props to dispatch them. For example, by wrapping onCanPlay
we may mediate the canplay
event by delaying it by 500 milliseconds:
const onVideoCanPlay = useCallback((e) => {
setTimeout(() => {
onCanPlay(e);
}, 500);
}, [onCanPlay]);
We set the wrapped method as the <video>
's actual event handler:
<video
ref={ref}
- onCanPlay={onCanPlay}
+ onCanPlay={onVideoCanPlay}
You may similarly choose to pass or override properties at the higher level, passed from FantasticPlayer
into the VideoWrapper
we instantiate. For a list of these properties see amp-video/1.0/component.type.js
AMP actions execute methods on the Preact component because they forwardRef
to an element that defines them, or because they define them with useImperativeHandle
. Methods executed down the Preact component chain cascade the same way.
When we click the following button on an AMP document:
<button on="tap: my-fantastic-player.play">
Play
</button>
We call the corresponding function play
:
The AMP action
my-element.play
is declared to be forwarded to the Preact component's method. See theinit()
method onVideoBaseElement
for a list of the supported actions.
-> FantasticPlayer.play()
Since we don't call useImperativeHandle
at this layer, its ref
forwards to VideoWrapper
:
-> FantasticPlayer.play()
-> (forwardRef) VideoWrapper.play()
VideoWrapper
sets an imperative handle that explicitly calls component.play()
:
-> FantasticPlayer.play()
-> (forwardRef) VideoWrapper.play()
-> (imperativeHandle) component.play()
If component
is a <video>
, then the method is called direclty. If it's our FantasticPlayerInternal
as we defined earlier, it may either forwardRef
to a <video>
or implement its own imperative handle.
-> FantasticPlayer.play()
-> (forwardRef) VideoWrapper.play()
-> (imperativeHandle) component.play()
-> (forwardRef) <video>.play()
Methods can be defined with useImperativeHandle
at the component
implementation. We no longer forward our ref
to the <video>
, we use it to define the imperative handle instead. Downstream methods on the <video>
element are executed explicitly through a local videoRef
.
The methods and getters listed are the current requirements from
VideoWrapper
. Note that video players on Bento are in active development, so this list might expand in the future.
// component.js
// ...
function FantasticPlayerInternalWithRef({sources, ...rest}, ref) {
const videoRef = useRef(null);
useImperativeHandle(() => {
return {
play() {
videoRef.current.play();
},
pause() {
videoRef.current.pause();
},
requestFullscreen() {
videoRef.current.requestFullscreen();
},
get readyState() {
return videoRef.current.readyState;
},
get currentTime() {
return videoRef.current.currentTime;
},
get duration() {
return videoRef.current.duration;
},
}
}, []);
return (
<video ref={videoRef} {...rest}>
{sources}
</video>
);
}
Follow the guide to Building a Bento AMP Component for other instructions that you should complete, including:
- Documentation that describes the component.
- Tests that verify the component's functionality.
- Validator rules to embed the component in an AMP document.
- An example to our Storybook or to be published on amp.dev