Build video speed controller using RxJS and Angular

Connie Leung - Jan 8 '23 - - Dev Community

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.

Create a new Angular project

ng generate application day28-video-speed-controller
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode
// 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 { }
Enter fullscreen mode Exit fullscreen mode

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.ts
Enter fullscreen mode Exit fullscreen mode
// 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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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),
      );
 }
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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

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

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')
);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .