YouTube player for React Native that also works on the Web
Published on
The de facto library to use a YouTube player with React Native is react-native-youtube-iframe. Although is works perfectly on native platforms, it has some issues when used on the Web.
Recently I tried to integrate the really great react-native-youtube-iframe library in an Expo application that needs to work for native platforms and for the Web. I had to create custom controls and to play videos from a playlist.
To make react-native-youtube-iframe work for the Web, I had to install react-native-web-webview. Thanks to this library we can use WebViews with React Native Web. react-native-youtube-iframe uses it internally.
From my experience, react-native-youtube-iframe works perfectly on native platforms. I could control which video to play, and I could pause and play the player programmatically. On the Web, the player itself also works. We can play and pause videos manually.
The biggest issue I faced is that it is currently not possible to control the player programmatically on the Web. This is due to the fact that to communicate with the WebView that hosts the YouTube player, react-native-youtube-iframe asks react-native-web-webview to inject JavaScript code inside the WebView, and currently, react-native-web-webview does not support this feature. It is a known bug from react-native-web-webview side and also from react-native-youtube-iframe but in the meantime there is no recommended solution. We will have to find one ourselves.
permalinkFind a solution
We can not use react-native-youtube-iframe as the player on the Web but we can still use it on native platforms. React Native provides a way to write code that differs according to the platform.
We will use Platform.select
to choose which library to load.
Create components/Player/index.tsx
:
tsx
import { Platform } from "react-native";export default Platform.select({native: () => require('./native').default,default: () => require('./web').default,})()
This component is kind of a proxy. If the application runs on a native platform, it will load the native
file, otherwise it will load the web
file.
The objective is to have these two files exporting a component with the same API, so that we can use them interchangeably. Upper components using our YouTube component should not have to care about its actual implementation.
On the Web we will use react-youtube.
permalinkThe Contract
The two components we want to create, one using react-native-youtube-iframe, the other one using react-youtube, will have to conform to the same contract, that is, they must have the same public API surface.
Let’s elaborate the contract we expect these both component to conform to.
Create components/Player/contract.ts
:
tsx
export interface PlayerProps {height: number;width: number;videoId: string;playing: boolean;}export interface PlayerRef {getDuration(): Promise<number>;}
We expect each component to take four mandatory props, height
, width
, videoId
and playing
. We expect references to these components to have a method, getDuration
, that resolves to a number representing the duration of the current track.
Now that we established the contact, let’s begin with the native player.
permalinkNative Player
Let’s begin components/Player/native.tsx
:
tsx
import { forwardRef } from "react";import { PlayerProps, PlayerRef } from "./contract";const NativePlayer = forwardRef<PlayerRef, PlayerProps>(({}) => {return null;});export default NativePlayer;
The NativePlayer
component needs to customise the reference it will receive. Indeed, we need to add a getDuration
method that interacts with the native player. To do that, we need to use forwardRef. With forwardRef
, we can catch the reference provided by the upper component and do things with it, such as passing it down to a specific child component, or customising it. With React Hooks, the way to customise a reference is to use useImperativeHandle.
Before digging deeper with references, let’s use react-native-youtube-iframe and pass it some props.
It’s the moment to install react-native-youtube-iframe:
bash
yarn add react-native-youtube-iframe
Now update components/Player/native.tsx
:
tsx
import React, { forwardRef } from "react";import { PlayerProps, PlayerRef } from "./contract";import YoutubePlayer from "react-native-youtube-iframe";const NativePlayer = forwardRef<PlayerRef, PlayerProps>(({ width, height, videoId, playing }) => {return (<YoutubePlayervideoId={videoId}height={height}width={width}play={playing}/>);});export default NativePlayer;
Here we pass down props NativePlayer
receives. If you don’t feel confortable with this code, check FrontendMasters courses about React. FrontendMasters has a lot of extremely good content and you can have six free months if you are a student.
Now that props are passed to the player, we need to take care of adding a method to the reference.
Firstly, let’s create a reference to the react-native-youtube-iframe player and provide it to the player. This is through this reference that we will access the duration of the currently played track.
tsx
import React, { forwardRef, useRef } from "react";import { PlayerProps, PlayerRef } from "./contract";import YoutubePlayer, { YoutubeIframeRef } from "react-native-youtube-iframe";const NativePlayer = forwardRef<PlayerRef, PlayerProps>(({ width, height, videoId, playing }) => {const playerRef = useRef<YoutubeIframeRef>(null);return (<YoutubePlayerref={playerRef}videoId={videoId}height={height}width={width}play={playing}/>);});export default NativePlayer;
And now let’s customise the reference forwarded to NativePlayer
:
tsx
import React, { useRef, useImperativeHandle, forwardRef } from "react";import YoutubePlayer, { YoutubeIframeRef } from "react-native-youtube-iframe";import { PlayerComponent, PlayerProps, PlayerRef } from "./contract";const NativePlayer: PlayerComponent = forwardRef<PlayerRef, PlayerProps>(({ width, height, videoId, playing }, ref) => {const playerRef = useRef<YoutubeIframeRef>(null);useImperativeHandle(ref, () => ({async getDuration() {const duration = await playerRef.current?.getDuration();if (duration === undefined) {throw new Error("Could not get duration from react-native-youtube-iframe");}return duration;},}));return (<YoutubePlayerref={playerRef}videoId={videoId}height={height}width={width}play={playing}/>);});export default NativePlayer;
With useImperativeHandle
we say that the value of the reference will now be an object with a single method, getDuration
. In the definition of the method getDuration
, we use the reference to the react-native-youtube-iframe
player and its own method getDuration
.
Here we have to deal with two React references. The first one, called ref
, and the second one, called playerRef
. ref
is the reference passed by the parent component that use our player. playerRef
is the reference to the real player, here react-native-youtube-iframe, and that we only care in the implementation of our custom player, never outside. In summary, ref
is the reference passed by consumers of our component, and they will use it to programatically trigger actions, and playerRef
is the reference we use inside our implementation to interact with the YouTube player library.
Now let’s implement the Web player.
permalinkWeb Player
Let’s create components/Player/web.tsx
:
tsx
import React, {useRef,useEffect,useImperativeHandle,forwardRef,} from "react";import YouTube, { Options } from "react-youtube";import { PlayerComponent, PlayerProps, PlayerRef } from "./contract";import { YoutubeIframePlayer } from "./youtube-iframe";const WebPlayer: PlayerComponent = forwardRef<PlayerRef, PlayerProps>(({ width, height, videoId, playing }, ref) => {const playerRef = useRef<YoutubeIframePlayer>();useImperativeHandle(ref, () => ({getDuration() {const duration = playerRef.current?.getDuration();if (duration === undefined) {throw new Error("Could not get duration from react-youtube");}return Promise.resolve(duration);},}));useEffect(() => {if (playing === true) {playerRef.current?.playVideo();} else {playerRef.current?.pauseVideo();}}, [playing, playerRef]);const playerOptions: Options = {height: String(height),width: String(width),};function setPlayerRef(ref: YouTube) {if (ref === null) {return;}playerRef.current = ref.getInternalPlayer() as YoutubeIframePlayer;}return (<YouTube ref={setPlayerRef} videoId={videoId} opts={playerOptions} />);});export default WebPlayer;
And install react-youtube:
bash
yarn add react-youtube
Contrary to react-native-youtube-iframe, to access play/pause controls we must call getInternalPlayer
method on react-youtube reference, that returns the YouTube player used internally. This can be done by passing a callback ref instead of a ref. When the reference needs to be set, React will call the callback with the reference and we can do whatever we want with it. In our case, we want to retrieve the YouTube player through getInternalPlayer
and then assign it to playerRef
reference. We need one extra step then with react-native-youtube-iframe. Furthermore, the return type of getInternalPlayer
is any
. This is not really handy to work with so we need to fix that. What getInternalPlayer
returns is an official YouTube player instance. There are official types for the YouTube player but they need to be set globally on the project and I do not want to be able to access YT
from everywhere in my application. As a consequence I decided to copy and paste only the definitions I needed from the official typings repository.
Let’s create components/Player/youtube-iframe.ts
:
tsx
/*** Copy pasted from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/youtube/index.d.ts*/export interface YoutubeIframePlayer {/*** Plays the currently cued/loaded video.*/playVideo(): void;/*** Pauses the currently playing video.*/pauseVideo(): void;/*** @returns Elapsed time in seconds since the video started playing.*/getCurrentTime(): number;/*** @returns Duration in seconds of the currently playing video.*/getDuration(): number;}
I kept only methods of YoutubeIframePlayer
that interest me: play and pause, get current elapsed time and get the duration of the current track.
There is one more specificity of react-youtube over react-native-youtube-iframe: the player does not expect a prop to control the playing state of the video but let’s its user control it imperatively. Therefore we reproduced a declarative approach by using a useEffect
hook.
tsx
useEffect(() => {if (playing === true) {playerRef.current?.playVideo();} else {playerRef.current?.pauseVideo();}}, [playing, playerRef]);
If the playing
prop is true, we imperatively play the track, otherwise we pause it.
We set a getDuration
method to the parent reference the same way we did for native platforms, with the exception that now playerRef.current?.getDuration()
returns a number, not a promise. Therefore we need to wrap it in a resolve promise using [Promise.resolve()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve)
.
Now that we have both a native player and a web player, let’s see if they work properly.
permalinkDemonstration Time
Let’s update the App.tsx
file to use our custom player and show several buttons to control the player:
tsx
import { StatusBar } from "expo-status-bar";import React, { useState, useRef } from "react";import { Button, StyleSheet, Text, View } from "react-native";import Player from "./components/Player";import { PlayerRef } from "./components/Player/contract";export default function App() {const playerRef = useRef<PlayerRef>(null);const [playerPlaying, setPlayerPlaying] = useState(false);const [duration, setDuration] = useState(0);async function handleComputeDuration() {const player = playerRef.current;if (player === null) {return;}const duration = await player.getDuration();setDuration(duration);}return (<View style={styles.container}><Text>Open up App.tsx to start working on your app!</Text><Playerref={playerRef}width={300}height={200}videoId="eSzNNYk7nVU"playing={playerPlaying}/><Buttontitle={`Press me to ${playerPlaying ? 'pause' : 'play'}`}onPress={() => {setPlayerPlaying(!playerPlaying);}}/><Text>Duration of the current track: {duration} seconds</Text><Buttontitle="Compute duration of the current track"onPress={handleComputeDuration}/><StatusBar style="auto" /></View>);}const styles = StyleSheet.create({container: {flex: 1,backgroundColor: "#fff",alignItems: "center",justifyContent: "center",},});
To see if it works, let’s start the Expo application:
bash
yarn start
We want to test if it works for both native platforms and for the Web. Launch the Android/iOS simulator as well as the Web bundler.
Screenshot of Expo instructions to start simulators
Press a
or i
to launch for Android or for iOS and w
to launch the Web bundler.
I will try the application on iOS and on the Web.
Successful demonstration on iOS simulator
The test works on iOS simulator. We can play and pause the video. We can also get its duration.
Successful demonstration on Firefox
The test also works on the Web.
permalinkConclusion
React Native is a lovely way to write applications that work on native platforms and on the Web, with a single and common codebase. Despite most of the time you do not have to care where your application runs, sometimes you need to write code specific to some platforms. You can reduce the dependency to the platform by isolating it and by creating an abstraction on top of it so that it stays in a black box. This is what we did, to have a YouTube player we can control on native platforms and on the Web.