How I learned Rxjs by making a YouTube clip looper in JavaScript
Tags: rxjs, javascript, showdev, webdev
Published: 2020-04-09 10:50:44

---
title: How I learned Rxjs by making a YouTube clip looper in JavaScript
published: true
description: Loading an external script source, creating a YouTube iframe player, handling input changes, button clicks, and video player events - these are all asynchronous operations that can be tied together with the power of Rxjs Observables.
tags: rxjs, javascript, showdev, webdev
---
[Rxjs](https://rxjs.dev/guide/overview) 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:
1. Loading the YouTube Player API into the page.
2. Initiating a YouTube player embed for a specific video.
3. Submitting valid start and end times for a new clip of the video to loop.
4. 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](https://developers.google.com/youtube/iframe_api_reference) 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:
```javascript
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.
```javascript
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:
```javascript
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"](https://www.youtube.com/watch?v=Nj2U6rhnucI) video for demonstration. I hope you enjoy it.
```javascript
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`](https://rxjs.dev/api/operators/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](https://rxjs.dev/guide/subject) and the similar [`share`](https://rxjs.dev/api/operators/share) operator.

Let's test what we have so far by subscribing to our `playerObservable`, and calling the [`playVideo`](https://developers.google.com/youtube/iframe_api_reference#playVideo) method on our player when it is emitted by the Observable:
```javascript
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"*](https://www.theguardian.com/music/2020/mar/27/dua-lipa-future-nostalgia-review) 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:
```html
<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`](https://rxjs.dev/api/index/function/fromEvent) function, which deals with adding/removing eventListeners for us:
```javascript
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`](https://rxjs.dev/api/operators/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`](https://rxjs.dev/api/index/function/combineLatest):
```javascript
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`](https://rxjs.dev/api/index/function/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:
```javascript
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`](https://developers.google.com/youtube/iframe_api_reference#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.
```javascript
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`](https://rxjs.dev/api/index/function/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`](https://rxjs.dev/api/index/function/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:
```javascript
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`](https://rxjs.dev/api/operators/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`](https://developers.google.com/youtube/iframe_api_reference#seekTo) method on our player object:
```javascript
newPlayerLoops.subscribe({
  next: ([loop, player]) => {
    player.seekTo(loop.start, true);
  }
});
```

We are also  going to need Observables for player events:
```javascript
const playerStateChanges = playerObservable.pipe(
  concatMap(player => fromEvent(player, "onStateChange")),
  share()
);
```
Using the [`concatMap`](https://rxjs.dev/api/operators/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 for `playerStateChanges` where the state is `PLAYING`.
- 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 not `PLAYING` 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 from `newPlayerLoops` 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:
```javascript
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`](https://rxjs.dev/api/operators/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`](https://rxjs.dev/api/index/function/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`](https://rxjs.dev/api/index/function/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`](https://developers.google.com/youtube/iframe_api_reference#getCurrentTime) method.

Finally, the [`takeUntil`](https://rxjs.dev/api/operators/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`](https://developers.google.com/youtube/iframe_api_reference#onPlaybackRateChange) event:
```javascript
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`](https://developers.google.com/youtube/iframe_api_reference#getPlaybackRate) method on the player:
```javascript
// 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:
```javascript
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:
{% codesandbox 9wnfp %}

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!