DEV Community

adam meier
adam meier

Posted on • Edited on

How I learned Rxjs by making a YouTube clip looper in JavaScript

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:

  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 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 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:

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)