Use RxJS and Angular to build a custom video player

Reading Time: 8 minutes

Loading

Introduction

This is day 11 of Wes Bos’s JavaScript 30 challenge and I am going to use RxJS and Angular to build a custom video player. The player has the following functionalities:

  • pause and play video
  • fast forward the video by 25 seconds
  • backward the video by 10 seconds
  • increase/decrease the volume
  • increase/decrease playback rate
  • click progress bar

In this blog post, I describe how to create a video player component that encapsulates a native video element and child video controls components. Then, I use RxJS and Angular to implement each of the functionalities and use Subject/Observable to communicate between parent and child components.

let's go

Create a new Angular project

ng new application day11-custom-video-player

Create Video Player feature module

First, we create a VideoPlayer feature module and import it into AppModule. The feature ultimately encapsulates VideoPlayerComponent and VideoPlayerControlsComponent

Import VideoPlayerModule in AppModule

// video-player.module.ts

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

// app.module.ts

function getBaseHref(platformLocation: PlatformLocation): string {
  return platformLocation.getBaseHrefFromDOM();
}

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    VideoPlayerModule
  ],
  providers: [
    {
      provide: APP_BASE_HREF,
      useFactory: getBaseHref,
      deps: [PlatformLocation]
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

APP_BASE_HREF is an injector that injects the base href of the mp4 video. VideoPlayerComponent applies the injected value to derive the full url of the video and subsequently sets the source of the native <video> element.

Declare Video components in feature module

In VideoPlayer feature module, we declare two Angular components, VideoPlayerComponent and VideoPlayerControlsComponent to compose the video player. Then, the application uses the technique of RxJS and Angular to pass data between parent and child components

src/app/video-player
├── enums
│   ├── index.ts
│   └── video-actions.enum.ts
├── index.ts
├── interfaces
│   ├── index.ts
│   ├── video-action.interface.ts
│   └── video-player.interface.ts
├── services
│   ├── index.ts
│   ├── video-player.service.spec.ts
│   └── video-player.service.ts
├── video-player
│   ├── video-player.component.spec.ts
│   └── video-player.component.ts
├── video-player-controls
│   ├── video-player-controls.component.scss
│   ├── video-player-controls.component.spec.ts
│   └── video-player-controls.component.ts
└── video-player.module.ts

In VideoPlayerComponent, we define app selector, inline template and inline CSS styles. We will add the RxJS codes to implement the functions in later sections. For your information, <app-video-player-controls> is the tag of VideoPlayerControlsComponent.

import { APP_BASE_HREF } from '@angular/common';
import { Component, OnInit, ChangeDetectionStrategy, Inject, ViewChild, ElementRef, OnDestroy } from '@angular/core';
import { fromEvent, map, merge, Subscription, tap } from 'rxjs';
import { VideoActionEnum } from '../enums';
import { VideoAction, VideoPlayerRangeInput } from '../interfaces';
import { VideoPlayerService } from '../services';

@Component({
  selector: 'app-video-player',
  template: `
    <div class="player">
      <video class="player__video viewer" currentTime="10" #video>
        <source [src]="videoSrc" type="video/mp4">
      </video>
      <app-video-player-controls></app-video-player-controls>
    </div>
  `,
  styles: [`
    :host {
      display: flex;
      background: #7A419B;
      min-height: 100vh;
      background: linear-gradient(135deg, #7c1599 0%,#921099 48%,#7e4ae8 100%);
      background-size: cover;
      align-items: center;
      justify-content: center;
    }

    .player {
        max-width: 750px;
        border: 5px solid rgba(0,0,0,0.2);
        box-shadow: 0 0 20px rgba(0,0,0,0.2);
        position: relative;
        font-size: 0;
        overflow: hidden;
    }
      
    /* This css is only applied when fullscreen is active. */
    .player:fullscreen {
        max-width: none;
        width: 100%;
    }
      
    .player:-webkit-full-screen {
        max-width: none;
        width: 100%;
    }
      
    .player__video {
        width: 100%;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class VideoPlayerComponent implements OnInit, OnDestroy {

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

  ngOnInit(): void {}

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

  ngOnDestroy(): void {}
}

Similar, I define app selector, inline template and CSS styles in VideoPlayerControlsComponent.

@Component({
  selector: 'app-video-player-controls',
  template: `
    <div class="player__controls">
      <div class="progress" #progress>
        <div class="progress__filled" [style.flexBasis]="videoProgressBar$ | async"></div>
      </div>
      <button class="player__button toggle" title="Toggle Play" [textContent]="videoButtonIcon$ | async" #toggle>►</button>
      <input type="range" name="volume" class="player__slider" min="0" max="1" step="0.05" value="1" #range>
      <input type="range" name="playbackRate" class="player__slider" min="0.5" max="2" step="0.1" value="1" #range>
      <button data-skip="-10" class="player__button" #skip>« 10s</button>
      <button data-skip="25" class="player__button" #skip>25s »</button>
    </div>
  `,
  styleUrls: ['./video-player-controls.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class VideoPlayerControlsComponent implements OnInit, OnDestroy, AfterViewInit {

  constructor() { }

  ngOnInit(): void {}

  ngAfterViewInit(): void {}

  ngOnDestroy(): void {}
}

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

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 = 'Day11 HTML Video Player';

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

Add video service to share RxJS subjects and observables

In order to communicate data between VideoPlayerComponent and VideoPlayerControlsComponent, I add a VideoPlayerService to store Subjects and Observables that the components subscribe to stream events.

@Injectable({
  providedIn: 'root'
})
export class VideoPlayerService {
  private readonly videoButtonIconSub = new Subject<string>();
  private readonly videoProgressBarSub = new Subject<string>();
  private readonly videoActionSub = new Subject<VideoAction>();

  readonly videoButtonIcon$ = this.videoButtonIconSub.asObservable();
  readonly videoProgressBar$ = this.videoProgressBarSub.asObservable();
  readonly videoAction$ = this.videoActionSub.asObservable();

  updateVideoButtonIcon(icon: string) {
    this.videoButtonIconSub.next(icon);
  }

  updateVideoProgressTime(flexBasis: string) {
    this.videoProgressBarSub.next(flexBasis);
  }

  updateVideoAction(action: VideoAction): void {
    this.videoActionSub.next(action);
  }
}

Use RxJS and Angular to implement Video Player functions

We are going to use RxJS to implement the following events:

  • Click <video> element to play or pause video
  • Update button icon when video is playing or pausing
  • Update progress bar when video is playing

Click <video> element to play or pause video

Use ViewChild to obtain the reference to video player

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

Declare subscription instance member and unsubscribe in ngDestroy()

subscription = new Subscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}

Create observable from click event, feed the value to videoActionSub subject and subscribe in ngOnInit().

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

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

    const togglePlay$ = fromEvent(videoNativeElement, 'click')
        .pipe(
          map(() => ({ action: VideoActionEnum.TOGGLE_PLAY, arg: undefined })),
          tap(nextAction => this.videoService.updateVideoAction(nextAction))
        )
        .subscribe();

    this.subscription.add(togglePlay$);
}

In ngOnInit(), subscribe to this.videoService.videoAction$ to obtain the value and process video action.

this.subscription.add(this.videoService.videoAction$.subscribe(nextAction => this.processAction(videoNativeElement, nextAction))
);
private processAction(videoNativeElement: HTMLVideoElement, nextAction: VideoAction): void {
    ...  other logic ...
    } else if (nextAction.action === VideoActionEnum.TOGGLE_PLAY) {
      const methodName = videoNativeElement.paused ? 'play' : 'pause';
      videoNativeElement[methodName]();
    }
    ... other logic ...
  }

Update button icon when video is playing or pausing

this.subscription.add(
    merge(
       fromEvent(videoNativeElement, 'pause').pipe(map(() => '►')), 
       fromEvent(videoNativeElement, 'play').pipe(map(() => '❚ ❚')),
    )
    .pipe(tap(icon => this.videoService.updateVideoButtonIcon(icon)))
    .subscribe()
);

In VideoPlayerControlsComponent, we subscribe this.videoPlayService.videoButtonIcon$ to render the textContent of the toggle button.

Update progress bar when video is playing

this.subscription.add(
      fromEvent(videoNativeElement, 'timeupdate')
        .pipe(
          map(() => { 
            const progressTime = (videoNativeElement.currentTime / videoNativeElement.duration) * 100;
            return `${progressTime}%`;
          }),
          tap(flexBasis => this.videoService.updateVideoProgressTime(flexBasis))
        )
        .subscribe()
);

In VideoPlayerControlsComponent, we subscribe this.videoPlayerService.videoProgressBar$ to adjust the flexBasic style of the progress bar.

Use RxJS and Angular to implement UI functions Video Player Controls

We are going to use RxJS to implement the UI functions of video player controls:

  • Click button to play or pause video. (Implemented in ngOnInit)
  • Click progress bar to play a specific frame of the video. (Implemented in ngOnInit)
  • Drag progress bar to play a specific frame of the video. (Implemented in ngOnInit)
  • Click forward and backward buttons to change the video time of the video. (Implemented in ngAfterViewInit)
  • Change the volume of the video. (Implemented in ngAfterViewInit)
  • Change the playback rate of the video. (Implemented in ngAfterViewInit)

Similarly, declare subscription instance member and unsubscribe in ngDestroy()

constructor(private videoPlayerService: VideoPlayerService) { }

subscription = new Subscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}

Click button to play or pause video

@ViewChild('toggle', { static: true })
toggleButton!: ElementRef<HTMLButtonElement>;

ngOnInit(): void {
    this.subscription.add(
      fromEvent(this.toggleButton.nativeElement, 'click')
        .pipe(
          map(() => ({ action: VideoActionEnum.TOGGLE_PLAY, arg: undefined })),
          tap(nextAction => this.videoPlayerService.updateVideoAction(nextAction))
        ).subscribe()
    );
}

Click progress bar to play a specific frame of the video.

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

private createProgressBarAction(action: VideoActionEnum, offsetX: number): VideoAction {
    return { action, arg: offsetX / this.progress.nativeElement.offsetWidth };
}

ngOnInit(): void {
    ...

    const progressNativeElement = this.progress.nativeElement;
    this.subscription.add(
      fromEvent(progressNativeElement, 'click')
        .pipe(
          filter(event => event instanceof PointerEvent),
          map(event => event as PointerEvent),
          map(({ offsetX }) => this.createProgressBarAction(VideoActionEnum.PROGESS_BAR_CLICKED, offsetX)),
          tap(nextAction => this.videoPlayerService.updateVideoAction(nextAction))
        ).subscribe()
    );
}

In VideoPlayerComponent, we subscribe this.videoPlayerService.videoAction$ to update the current time of the video.

Drag progress bar to play a specific frame of the video.

ngOnInit(): void {
    ...

    const mouseDown$ = fromEvent(progressNativeElement, 'mousedown');
    const drag$ = fromEvent(progressNativeElement, 'mousemove').pipe(
      takeUntil(fromEvent(progressNativeElement, 'mouseup'))
    );

    this.subscription.add(
      mouseDown$
        .pipe(
          concatMap(() => drag$.pipe(
            filter(event => event instanceof MouseEvent),
            map(event => event as MouseEvent),
            map(({ offsetX }) => this.createProgressBarAction(VideoActionEnum.PROGRESS_BAR_DRAGGED, offsetX))
          )),
          tap(nextAction => this.videoPlayerService.updateVideoAction(nextAction))        
        ).subscribe()
    );
}

In VideoPlayerComponent, we subscribe this.videoPlayerService.videoAction$ to update the current time of the video.

Click forward and backward buttons to change the video time of the video.

@ViewChildren('range', { read: ElementRef })
rangeInputs!: QueryList<ElementRef<HTMLInputElement>>

ngAfterViewInit(): void {
    const skipButtonEvents$ = this.skipButtons.reduce((acc, skipButton) => {
      const clickEvent$ = fromEvent(skipButton.nativeElement, 'click').pipe(
        map(({ target }) => {
          const strSeconds = (target as HTMLButtonElement).dataset['skip'];
          const seconds = strSeconds ? +strSeconds : 0;
          return {
            action: VideoActionEnum.SKIP_BUTTON_CLICKED,
            arg: seconds
          }
        }),
      )

      return acc.concat(clickEvent$);
    }, [] as Observable<VideoAction>[])

    this.subscription.add(merge(...skipButtonEvents$).pipe(
tap(nextAction => this.videoPlayerService.updateVideoAction(nextAction))
    ).subscribe());
}

Change the volume or playback rate of the video

@ViewChildren('range', { read: ElementRef })
rangeInputs!: QueryList<ElementRef<HTMLInputElement>>;

ngAfterViewInit(): void {
    ...

    const rangeInputEvents$ = this.rangeInputs.reduce((acc, rangeInput) => 
      acc.concat(this.addRangeUpdateEvent(rangeInput, 'change'), this.addRangeUpdateEvent(rangeInput, 'mousemove'))
    , [] as Observable<VideoAction>[]);

    this.subscription.add(merge(...skipButtonEvents$, ...rangeInputEvents$)
      .pipe(tap(nextAction => this.videoPlayerService.updateVideoAction(nextAction)))
      .subscribe());
}

The following is the full implementation of addRangeUpdateEvent

private addRangeUpdateEvent(rangeInput: ElementRef<HTMLInputElement>, eventName: string): Observable<VideoAction> {
    return fromEvent(rangeInput.nativeElement, eventName).pipe(
      map(({ target }) => {
        const { name, value } = target as HTMLInputElement;
        return {
          action: VideoActionEnum.RANGE_UPDATED,
          arg: {
            name: name as "volume" | "playbackRate",
            value: +value
          }
        }
      })
    );
  }

Subscribe RxJS observable and render Angular component

Use async pipe to subscribe this.videoPlayService.videoButtonIcon$ observable and update textContent of the button

videoButtonIcon$ = this.videoPlayerService.videoButtonIcon$.pipe(startWith('►'));

In inline template, update textContent attribute

<button class="player__button toggle" title="Toggle Play" [textContent]="videoButtonIcon$ | async" #toggle>►</button>

Similarly, the async pipe subscribes this.videoPlayerService.videoProgressBar$ to dynamically set the flex basis of the progress bar

videoProgressBar$ = this.videoPlayerService.videoProgressBar$

In inline template, bind the value to flexBasis style

<div class="progress" #progress>
   <div class="progress__filled" [style.flexBasis]="videoProgressBar$ | async">  
   </div>
</div>

Process the value of Subject in VideoPlayerComponent

In VideoPlayerControlsComponent, I call this.videoPlayerService.updateVideoAction(nextAction) a few times to push the value to subject.

In VideoPlayerComponent, I check the nextAction value in processAction and update the attributes of <video> element.

private processAction(videoNativeElement: HTMLVideoElement, nextAction: VideoAction): void {
    if (nextAction.action === VideoActionEnum.SKIP_BUTTON_CLICKED) {
      const seconds = nextAction.arg as number;
      videoNativeElement.currentTime = videoNativeElement.currentTime + seconds
    } else if (nextAction.action === VideoActionEnum.RANGE_UPDATED) {
      const rangeInput = nextAction.arg as VideoPlayerRangeInput;
      videoNativeElement[rangeInput.name] = rangeInput.value
    } else if (nextAction.action === VideoActionEnum.TOGGLE_PLAY) {
      const methodName = videoNativeElement.paused ? 'play' : 'pause';
      videoNativeElement[methodName]();
    } else if ([VideoActionEnum.PROGESS_BAR_CLICKED, VideoActionEnum.PROGRESS_BAR_DRAGGED].includes(nextAction.action)) {
      const proportion = nextAction.arg as number;
      videoNativeElement.currentTime = proportion * videoNativeElement.duration;
    }
 }

The video advances or rewind back to the new current time when the next action of the player is VideoActionEnum.SKIP_BUTTON_CLICKED, VideoActionEnum.PROGRESS_BAR_DRAGGED or VideoActionEnum.PROGESS_BAR_CLICKED.

When the next action is VideoActionEnum.SKIP_BUTTON_CLICKED, VideoActionEnum.PROGESS_BAR_CLICKED or VideoActionEnum.PROGRESS_BAR_DRAGGED, the code modifies the current time of the video.

The code modifies volume or playback rate attribute of the video when the next action is VideoActionEnum.RANGE_UPDATED.

The application is done and we have a custom vide player built with RxJS and Angular.

Final Thoughts

In this post, I show how to use RxJS and Angular to build a custom video player. The first takeaway is to encapsulate subject and observable in a shared service to exchange data between parent and child components. The second takeaway is to use async pipe to resolve observable such that developer does not have to clean up the subscription. The final take is when subscription is created in ngOnInit or ngAfterViewInput, developers need to unsubscribe in ngDestroy.

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/day11-custom-video-player
  2. Live demo: https://railsstudent.github.io/ng-rxjs-30/day11-custom-video-player/
  3. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30