Build video speed controller using RxJS and Angular

Reading Time: 4 minutes

Loading

Introduction

This is day 28 of Wes Bos’s JavaScript 30 challenge and I am going to use RxJS and Angular to build a video speed controller.

In this blog post, I describe how to compose a RxJS stream to listen to mousemove event of video speed bar and emit value to two other streams to update 1) CSS height of the bar and 2) display the formatted playback rate.

let's go

Create a new Angular project

ng generate application day28-video-speed-controller

Create Video feature module

First, we create a video feature module and import it into AppModule. The feature module encapsulates VideoPlayerComponent that is the host of a video player and a speed bar.

Import VideoModule in AppModule

// video.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VideoPlayerComponent } from './video-player/video-player.component';

@NgModule({
  declarations: [
    VideoPlayerComponent
  ],
  imports: [
    CommonModule
  ],
  exports: [
    VideoPlayerComponent
  ]
})
export class VideoModule { }
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VideoPlayerComponent } from './video-player/video-player.component';

@NgModule({
  declarations: [
    VideoPlayerComponent
  ],
  imports: [
    CommonModule
  ],
  exports: [
    VideoPlayerComponent
  ]
})
export class VideoModule { }

// app.module.ts 

import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { VideoModule } from './video'

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    VideoModule
  ],
  providers: [
    {
      provide: APP_BASE_HREF,
      useFactory: (platformLocation: PlatformLocation) => platformLocation.getBaseHrefFromDOM(),
      deps: [PlatformLocation]
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Declare Video component in feature module

In Video feature module, we declare VideoPlayerComponent and implement the logic to control the playback rate of the video player.

src/app/
├── app.component.spec.ts
├── app.component.ts
├── app.module.ts
└── video
    ├── index.ts
    ├── video.module.ts
    └── video-player
        ├── video-player.component.spec.ts
        └── video-player.component.tspa
// video-player.component.ts

import { APP_BASE_HREF } from '@angular/common';
import { ChangeDetectionStrategy, Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core';
import { filter, fromEvent, map, Observable, shareReplay, startWith, tap } from 'rxjs';

@Component({
  selector: 'app-video-player',
  template: `
    <div class="wrapper">
      <video class="flex" width="765" height="430" [src]="videoSrc" loop controls #video></video>
      <div class="speed" #speed>
        <div class="speed-bar" [style.height]="height$ | async">{{ playbackRate$ | async }}</div>
      </div>
    </div>
  `,
  styles: [`...omitted due to brevity....`],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class VideoPlayerComponent implements OnInit {

  height$!: Observable<string>;
  playbackRate$!: Observable<string>;

  constructor(@Inject(APP_BASE_HREF) private baseHref: string) {}

  get videoSrc(): string {
    const isEndWithSlash = this.baseHref.endsWith('/');
    return `${this.baseHref}${isEndWithSlash ? '' : '/'}assets/video.mp4`;
  }

  ngOnInit(): void {
    this.height$ = of('');    
    this.playbackRate$ = of('1x');
  }
}

Next, I delete boilerplate codes in AppComponent and render VideoPlayerComponent in inline template.

// app.component.ts

import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  template: '<app-video-player></app-video-player>',
  styles: [`
  :host {
    display: block;
  }
  `]
})
export class AppComponent {
  title = 'Day28 Video Speed Controller';

  constructor(titleService: Title) {
    titleService.setTitle(this.title);
  }
}

Implement RxJS stream to listen to mousemove event

Use ViewChild to obtain references to the video player and the speed bar

@ViewChild('video', { static: true, read: ElementRef })
video!: ElementRef<HTMLVideoElement>;

@ViewChild('speed', { static: true, read: ElementRef })
speed!: ElementRef<HTMLDivElement>;

In ngOnit, I declare an observable to listen to mousemove event.

ngOnInit(): void {
    const nativeElement = this.speed.nativeElement;

    const mouseMove$ = fromEvent(nativeElement, 'mousemove')
      .pipe(
        filter((e) => e instanceof MouseEvent),
        map((e) => e as MouseEvent),
        map((e) => {
          const y = e.pageY - nativeElement.offsetTop;
          const percent = y / nativeElement.offsetHeight;
          const min = 0.4;
          const max = 4;
          return {
            height: `${Math.round(percent * 100)}%`,
            playbackRate: percent * (max - min) + min,
          };
        }),
        tap(({ playbackRate }) => this.video.nativeElement.playbackRate = playbackRate),
        shareReplay(1),
      );
 }

After deriving height and playback rate, the tap operator updates the playback rate of the video player.

Use shareReplay to cache height and playbackRate because moveMove$ source Observable will emit result to this.height$ and this.playbackRate$ later.

Complete height$ and playbackRate$ observables

Currently, height$ and playbackRate$ are Observables with hardcoded strings. It is going to change when mouseMove$ becomes their source Observable.

this.height$ applies map operator to extract height property from the object.

this.height$ = mouseMove$.pipe(map(({ height }) => height));

Use async pipe to resolve this.height$ and bind the value to CSS height.

<div class="speed-bar" [style.height]="height$ | async"></div>

this.playbackRate$ applies map to format the playback rate and startWith('1x') provides the initial value when mousemove event has not fired yet.

this.playbackRate$ = mouseMove$.pipe(
    map(({ playbackRate }) => `${playbackRate.toFixed(2)}x`),
    startWith('1x')
);

Use async pipe to resolve this.playbackRate$ and display the string inside the div element of the inline template

<div class="speed-bar" [style.height]="height$ | async">{{ playbackRate$ | async }}</div>

The example is done and we build a video speed controller that plays the video at different speed.

Final Thoughts

In this post, I show how to use RxJS and Angular to build video speed controller. One nice thing about this example is all Observables are reactive and do not require manual subscribe. Therefore, this example does not have ngOnDestroy method to unsubcribe any subscription. Async pipe is responsible to subscribe the observables and clean up the subscriptions automatically.

The second cool thing is mousemove$ observable leverages tap operator to update video player’s playback rate. Otherwise, I have to perform the update in subscribe and create a subscription that requires clean up. When mouseMove$ is not an observable, it cannot build height$ and playbackRate$ Observables and subscribe in the inline template.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day28-video-speed-controller
  2. Live demo: https://railsstudent.github.io/ng-rxjs-30/day28-video-speed-controller/
  3. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30