Rxjs is a library that lets us use all sorts of asynchronous or event-based data as composable streams known as Observables. If the idea is totally new to you, I recommend checking out the official docs or other tutorials, as I'm sure they can explain better than I can.
We'll be using a variety of Observables all together to create a little app that allows us to load a YouTube video, and control it by looping a portion of the video with inputs that can be filled in and submitted with a click of a button. The final product is linked in a codesandbox at the end of this article, so if you can't be bothered to read, or want to know if what I've written is worth reading, feel free to skip to the end!
This will involve tackling the following operations in order:
- Loading the YouTube Player API into the page.
- Initiating a YouTube player embed for a specific video.
- Submitting valid start and end times for a new clip of the video to loop.
- Handling player events and setting timers to have the player loop back to the start of the clip once it hits the end.
It's quite a number of complex asynchronous operations that have to be handled in the correct order to have everything run smoothly without anything breaking. Thankfully, rxjs makes our lives quite a lot easier.
Enough chit-chat then, let's start coding! Before anything else, make sure you have Rxjs available in your project. It's available on NPM as rxjs
.
1. Load the YouTube Player API into the page
The YouTube Player API is unfortunately not available as a downloadable and bundleabe module, but only as a JavaScript source we have to load into our page. Once it's loaded, it calls a function that we define. Sound asynchronous? Of course! Let's wrap it in an Observable.
First, let's write a function that will add a script to the page:
function addScript(src) {
const { head } = document;
const isAdded = Array.from(head.getElementsByTagName("script")).some(
// here we check if the script has already been added to the page
s => s.src === src
);
if (!isAdded) {
const script = document.createElement("script");
script.type = "text/javascript";
script.async = true;
script.src = src;
head.appendChild(script);
}
// the function will return true if the script was already added, false otherwise
return isAdded;
}
Now let's create an Observable to represent the loading of the API. The Observable will just push a single value, the string "ready"
, once the API loads, before completing. When the Observable is subscribed to, it will use the addScript
function we defined. When the YouTube API loads, it automatically tries to call a function named onYouTubeIframeApiReady
, so let's define that to push the "ready" message to a subscriber. If we've somehow already loaded the API, we can ensure we still get the "ready" message. I wrapped the creation of the Observable in a function for easier importing, and in case it ever needs to be reused or recreated.
function fromYoutubeApiScript() {
return new Observable(subscriber => {
const scriptAdded = addScript("https://www.youtube.com/iframe_api");
if (!scriptAdded) {
window.onYouTubeIframeAPIReady = () => {
window.youTubeIframeAPIReady = true;
subscriber.next("ready");
subscriber.complete();
};
} else if (window.youTubeIframeAPIReady) {
subscriber.next("ready");
subscriber.complete();
} else {
subscriber.error("YouTube API loaded without using this Observable.");
}
});
}
Once the API is ready, it is exposed in your page as a big global JavaScript object, YT
. If you are using TypeScript, or your code editor can make use of type definitions, they are available for this YT
object on NPM as @types/youtube
.
2. Initiate a YouTube player embed for a specific video.
Loading the YouTube player is another asynchronous action, so, once again, we can wrap this in an Observable:
function fromNewYoutubePlayer(element, videoId) {
return new Observable(subscriber => {
new YT.Player(element, {
videoId,
events: {
onReady: playerEvent => {
subscriber.next(playerEvent.target);
subscriber.complete();
}
}
});
});
}
Once again, this is an Observable that pushes just one value, the Player
object representing the YouTube player we have loaded. To load our player, we need to provide an element
on our page as either an HTMLElement object, or a string containing the id of an element on our page. The videoId
is the YouTube ID of the video we will play.
Now, let's combine these two Observables together to first load the API, and then initiate a new YouTube player. Today I have chosen to use Dua Lipa's new "Break My Heart" video for demonstration. I hope you enjoy it.
const playerElement = document.getElementById("youtubePlayer");
const videoId = "Nj2U6rhnucI";
const playerObservable = fromYoutubeApiScript().pipe(
concatMapTo(fromNewYoutubePlayer(playerElement, videoId)),
shareReplay(1)
);
Once we retrieve the "ready" message from the fromYoutubeApiScript
Observable, we map the message to our new fromNewYoutubePlayer
Observable. This results in a nested Observable, so we want to flatten this into a single Observable. The concatMapTo
operator provided by rxjs does all of this work for us.
We also pipe our observable through the shareReplay
operator. This ensures that our playerObservable
can be casted to multiple subscribers while only ever creating a single YouTube player instance, and it will always give us the instance if it has already been emitted. You can read more on how this works with Subjects and the similar share
operator.
Let's test what we have so far by subscribing to our playerObservable
, and calling the playVideo
method on our player when it is emitted by the Observable:
playerObservable.subscribe({
next: player => {
player.playVideo();
}
});
As long as you have an element on your page with the id "youtubePlayer", and have followed the previous code, you should be hearing "pop visionary" Lipa's voice over some funky, disco inspired basslines. Feel free to delete the above code once you're sure it's working.
3. Submit valid start and end times for a new clip of the video to loop.
Before anything else, we need two input elements and a button on our page. The html should look something like this:
<input id="start" type="number" step="any" placeholder="0.0" min="0" />
<!-- optional labels, other divs, etc. -->
<input id="end" type="number" step="any" placeholder="0.0" min="0" />
<!-- more optional stuff -->
<button id="loop" disabled="true">LOOP</button>
Let's create Observables that emit values every time the input value changes. We can use the very handy fromEvent
function, which deals with adding/removing eventListeners for us:
const startInput = document.getElementById("start");
// we will do the same thing as here with our "end" input element
const startValues = fromEvent(startInput, "input").pipe(
map(e => Number.parseFloat(e.target.value))
);
Note that we are using the map
operator so that instead of on Observable of Events, we receive the value of the event target (the input element) parsed as a Number. This number will represent a timestamp in seconds.
This situation is not really ideal though; we would rather deal with start and end values paired together, rather than independently. what we want to do is combine them into one Observable. Yes, there's a function for that! Let's delete what we previously wrote for our inputs, and instead use fromEvent
Observables with combineLatest
:
const loopValues = combineLatest(
fromEvent(startInput, "input").pipe(
map(e => Number.parseFloat(e.target.value)),
startWith(0)
),
fromEvent(endInput, "input").pipe(
map(e => Number.parseFloat(e.target.value)),
startWith(0)
)
).pipe(map(values => ({ start: values[0], end: values[1] })));
This will give us an Observable emitting objects with start
and end
properties whenever one of the inputs changes. We use the startWith
operator to have our input Observables start with a default value of 0.
Now we need to ensure these loop values are valid. Let's write a function that takes a loop object and a YT.Player
object that returns a boolean representing the validity of the loop:
function validateLoop(loop, player) {
return (
Object.values(loop).every(val => val <= player.getDuration() && !isNaN(val)) &&
loop.start < loop.end &&
loop.start >= 0
);
}
With the above, we can ensure that each value is not NaN
(in case an input received a value like "asdf") or exceeding the duration of the current video (using the getDuration
method of our player). We also need to make sure that the start
value is greater than 0 and less than the end
value.
Now we can have separate Observables for both invalid and valid loops. Let's disable our loop button when we receive an invalid loop, and vice-versa.
const [validPlayerLoops, invalidPlayerLoops] = partition(
loopValues.pipe(withLatestFrom(playerObservable)),
([loop, player]) => validateLoop(loop, player)
);
const loopButton = document.getElementById("loop");
validPlayerLoops.subscribe({
next: () => {
loopButton.disabled = false;
}
});
invalidPlayerLoops.subscribe({
next: () => {
loopButton.disabled = true;
}
});
We use the partition
function to create two seperate Observables based on whether our validateLoop
function returns true or not. Before we run the predicate, we pipe loopValues
with the withLatestFrom
function on our playerObservable
to ensure we have a YT.Player
object to use in our function, and we also ensure that we only receive loopValues
after our player has finished loading. Neat!
Now we can make an Observable that emits the latest validPlayerLoops
value when the loopButton
is clicked:
const newPlayerLoops = fromEvent(loopButton, "click").pipe(
withLatestFrom(validPlayerLoops, (_, playerLoop) => playerLoop),
distinctUntilKeyChanged(0),
);
Again we are using the fromEvent
function and the withLatestFrom
operator. This time, because we don't actually care about the click event data, we strip it out and just pipe through the playerLoop
value. We then use the distinctUntilKeyChanged
operator to ensure that we only receive a new value when the loop value of the playerLoop
has changed ("0"
is the key of the loop inside the playerLoop
value).
4. Handle player events and start looping!
Finally we get to the fun stuff, incidentally the most complex too. Let's start by playing from the start of the new loop when we receive a value from newPlayerLoops
, using the seekTo
method on our player object:
newPlayerLoops.subscribe({
next: ([loop, player]) => {
player.seekTo(loop.start, true);
}
});
We are also going to need Observables for player events:
const playerStateChanges = playerObservable.pipe(
concatMap(player => fromEvent(player, "onStateChange")),
share()
);
Using the concatMap
function we map the player from playerObservable
into an Observable of player state change events, and concatenate the nested Observable into a single one. Thankfully, the YT.Player
object has both addEventListener
and removeEventListener
methods, meaning we can use it with the fromEvent
function without doing any extra work on our end! 🤯
Because adding and removing eventListeners is a fair bit of work, and we will have multiple subscribers to playerStateChanges
, let's pipe it through the share
operator, to avoid recreating eventListeners for each subscriber.
In order to get our player looping, we need to do the following:
- For each value from
newPlayerLoops
, listen forplayerStateChanges
where the state isPLAYING
. - When the player is playing, create a timer that emits once when the remaining time of the loop completes.
- If a new value from
playerStateChanges
which is notPLAYING
before the timer completes, cancel the timer. The process outlined in the previous two steps will repeat once the player is playing again, or if another value fromnewPlayerLoops
is received. - If the timer completes, set the player back to the start of the loop. If it's playing, it will emit a new
PLAYING
state change to start the process again.
Here it is using Observables:
function getRemainingTime(loop, player) {
return Math.max(loop.end - player.getCurrentTime(), 0) * 1000;
}
newPlayerLoops
.pipe(
switchMap(([loop, player]) =>
playerStateChanges.pipe(
filter(e => e.data === YT.PlayerState.PLAYING),
switchMapTo(
defer(() => timer(getRemainingTime(loop, player))).pipe(
map(() => [loop, player]),
takeUntil(
playerStateChanges.pipe(
filter(e => e.data !== YT.PlayerState.PLAYING)
)
)
)
)
)
)
)
.subscribe({
next: ([loop, player]) => {
player.seekTo(loop.start, true);
}
});
In the above, whenever we map one value to another Observable (resulting in a nested Observable), we use the switchMap
function to use the most recent inner Observable (this is what lets us loop for only the latest value from newPlayerLoops
, for example).
Then, when a PLAYING
state change occurs, a new single value Observable is created using the timer
function, which emits when the remaining time of the loop completes (I wrapped this calculation in its own getRemainingTime
function). The creation of this timer Observable is wrapped inside the defer
function so that the timer is only created when the PLAYING
state change occurs, giving us an up to date value from the getCurrentTime
method.
Finally, the takeUntil
operator is used so that when the player is not playing (e.g. is paused or buffering) before the timer is finished, the timer is cancelled.
Ta da! It should be running like clockwork 🕰️!
But wait, what if the player is playing at a speed other than 1x, or the speed changes? Our timer won't be accurate at all then 😬.
Thankfully, we can handle this using just a few extra lines of code. First, create an Observable that handles the onPlaybackRateChange
event:
const playerPlaybackRateChanges = playerObservable.pipe(
concatMap(player => fromEvent(player, "onPlaybackRateChange")),
share()
);
Then we use it in our chain of Observables, so that the timer is recalculated whenever the playback rate changes. Of course, we don't want to wait for an event to start the timer, so let's provide an initial value with the current playback rate using the startWith
operator and the getPlaybackRate
method on the player:
// same code as above
playerStateChanges.pipe(
filter(e => e.data === YT.PlayerState.PLAYING),
switchMapTo( // These are
playerPlaybackRateChanges.pipe( // the new
map(e => e.data), // lines we
startWith(player.getPlaybackRate()), // insert
switchMapTo(
defer(() => timer(getRemainingTime(loop, player))).pipe(
// same code as above
Lastly, use the getPlaybackRate
method in our getRemainingTime
function:
function getRemainingTime(loop, player) {
return (
(Math.max(loop.end - player.getCurrentTime(), 0) * 1000) /
player.getPlaybackRate()
);
}
Now we are done for real! Here is what I ended up with:
Try it out! Use fractional times, faster and slower playback rates, different videos etc. If you read all of this, or just skipped to the end to see the product in action, tell me what you think!
Top comments (0)