Manipulate video times using RxJS and Angular

Reading Time: 6 minutes

Loading

Introduction

This is day 18 of Wes Bos’s JavaScript 30 challenge where I am going to use RxJS operators and Angular to manipulate video times to calculate total, longest, shortest and average video time.

In this blog post, I make a http request to obtain an array of video times from an external source. Then, I turn the array to a stream of video times in order to leverage existing RxJS operators to perform computations. Even though RxJS does not provide average operator, I created a custom operator to achieve the task. I will show the different examples to manipulate video times to obtain what I need.

let's go

Create a new Angular project in workspace

ng generate application day18-adding-up-times      

Create Videos feature module

First, we create a Videos feature module and import it into AppModule. The feature module is consisted of VideoListComponent that encapsulates logic to manipulate video times.

Then, Import VideoListModule in AppModule

// videos.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VideoListComponent } from './video-list/video-list.component';
import { FormatTotalSecondsPipe } from './pipes/format-total-seconds.pipe';

@NgModule({
  declarations: [
    VideoListComponent,
    FormatTotalSecondsPipe
  ],
  imports: [
    CommonModule
  ],
  exports: [
    VideoListComponent
  ]
})
export class VideosModule { }

// app.module.ts

import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { VideosModule } from './videos';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    VideosModule,
    HttpClientModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Declare component and pipe in feature module

In Videos module, I declare VideoListComponent that displays the video times in a list. FormatTotalSecondsPipe is a custom pipe that formats total seconds into “x Hours y Minutes z Seconds” format.

The component calls VideoService to retrieve an array of video times and then use ngFor directive to iterate the data in <li> elements. Afterward, I use RxJS built-in and custom operators to explore statistics about the video times.

src/app
├── app.component.ts
├── app.module.ts
└── videos
    ├── custom-operators
    │   ├── average-video.operator.ts
    │   ├── index.ts
    │   └── minmax-video.operator.ts
    ├── index.ts
    ├── interfaces
    │   └── video-time.interface.ts
    ├── pipes
    │   └── format-total-seconds.pipe.ts
    ├── services
    │   └── video.service.ts
    ├── video-list
    │   └── video-list.component.ts
    └── videos.module.ts

I define component selector, inline template and inline CSS styles in the file. RxJS codes will be implemented in the later sections of the blog post. For your information, <app-video-list> is the tag of VideoListComponent.

// video-list.component.ts

import { concatMap, forkJoin, of, shareReplay } from 'rxjs';
import { VideoService } from '../services/video.service';

@Component({
  selector: 'app-sorted-list',
  template: `
    <section class="container">
    <h1>Add video times</h1>
    <section class="video-wrapper">
      <div class="video-list">
        <p>Video Name - Video Time</p>
        <ul *ngIf="videoList$ | async as videoList">
          <li *ngFor="let video of videoList">{{ video.name }} - {{ video.time }}</li>
        </ul>
      </div>
      <div class="video-total" *ngIf="items$ | async as x">
          <p>Video Total</p>
          <p>{{ x.total | formatTotalSeconds }}</p>
          <p>Longest Video</p>
          <ul>
            <li>max operator: {{ x.maxVideo.name }} - {{ x.maxVideo.time }}</li>
          </ul>
          <p>Shortest Video</p>
          <ul>
            <li>min operator: {{ x.minVideo.name }} - {{ x.minVideo.time }}</li>
          </ul>
          <p>Average Video Time</p>
          <p>{{ x.averageVideoTime | formatTotalSeconds }}</p>
      </div>
    </section>
  </section>
  `,
  styles:[`
    :host {
      display: block;
    }
    ... omitted for brevity...
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class VideoListComponent {
  videoList$ = of([]);

  streamVideoList$ = this.videoList$
    .pipe(
      tap(() => console.log('streamVideoList$ observable')),
      concatMap(videoTimes => from(videoTimes)),
      shareReplay(1)
    );

  items$ = forkjoin({
     total: of(0),
     maxVideo: of({
        name: '',
        time: '',
     }),
     minVideo: of({
        name: '',
        time: '',
     }),
     averageVideoTime: of(0)
  });

  constructor(private videoService: VideoService) { }

  private convertTotalSeconds(time: string): number {
    const [aMinutes, aSeconds] = time.split(':').map(parseFloat);
    return aSeconds + aMinutes * 60;
  }

  private compareVideoTimes (a: VideoTime, b: VideoTime) {
    const aTotalSeconds = this.convertTotalSeconds(a.time);
    const bTotalSeconds = this.convertTotalSeconds(b.time);

    return aTotalSeconds < bTotalSeconds ? -1 : 1;
  }
}

videoList$ is an Observable<VideoTime[]> and it is resolved in inline template by async pipe to render the array elements.

streamVideoList$ is an Observable<VideoTime> and it turns an array of VideoTime to a stream of VideoTime.

Why is streamVideoList$ important? When streamVideoList$ is a stream, I can use reduce, max, min and map to find the total, max, min and average video time. Otherwise, I have to subscribe videoList$ to VideoTime array and use Lodash to manipulate the video times.

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

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

@Component({
  selector: 'app-root',
  template: '<app-video-list></app-video-list>',
  styles: [`
    :host {
      display: block;
    }
  `]
})
export class AppComponent {
  title = 'Day18 Adding Up Times';

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

Make Http Request to retrieve video times

In this section, I will create VideoService to retrieve data.

// video-time.interface.ts

export interface Videos {
    videos: VideoTime[];
}

export interface VideoTime {
    name: string;
    time: string;
}

// video.service.ts

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { catchError, map, Observable, of } from 'rxjs';
import { Videos, VideoTime } from '../interfaces/video-time.interface';

@Injectable({
  providedIn: 'root'
})
export class VideoService {

  constructor(private httpClient: HttpClient) { }

  getAll(): Observable<VideoTime[]> {
    const url = 'https://gist.githubusercontent.com/railsstudent/9a53e81fc89e4ba04f8234ad8a560878/raw/c18b8cadaa607cc47063b8be230fbd79f49b3d64/video-times.json';

    return this.httpClient.get<Videos>(url)
      .pipe(
        map(({videos }) => videos),
        catchError(err => {
          console.error(err);
          return of([] as VideoTime[]);
        })         
      );
  }
}

In VideoListComponent, I inject VideoService and invoke getAll() to cache the result to videoList$.

videoList$ = this.videoService.getAll()
  .pipe(
      tap(() => console.log('videoList$ observable')),
      shareReplay(1)
   );

shareReplay(1) caches the result and ensures that streamVideoList$ does not make repeated Http request.

Create Custom AverageVideoTime operator

In this part, I demonstrate the creation of averageVideoTime operator to find the average video time. The source observable is type Observable<T> and it emits the value to reduce and map to calculate the average.

// average-video.operator.ts

import { map, Observable, reduce } from 'rxjs';

export function averageVideoTime<T>(accumulator: (acc: number, x: T) => number) {
    return function (source: Observable<T>) {
        return source.pipe(
            reduce((acc, item: T) => ({ 
                    total: accumulator(acc.total, item),
                    count: acc.count + 1
                }), { total: 0, count: 0 }),
            map((acc) => Math.floor(acc.total / Math.max(acc.count, 1)))
        )
    }
}

reduce emits the final total and count to map, performs division to obtain the average.

Apply RxJS operators to calculate video times

After the creation of averageVideoTime, I can fill up the forkjoin of items$ to find the total, longest, shortest and average video time respectively.

// video-list.component.ts

items$ = forkJoin({
    total: this.streamVideoList$
      .pipe(
        tap(() => console.log('videoTotal$ observable')),
        reduce((acc, videoTime) => acc + this.convertTotalSeconds(videoTime.time), 0)
      ),
    maxVideo: this.streamVideoList$
      .pipe(
        tap(() => console.log('mixMaxVideos$ observable')),
        max((x, y) => this.compareVideoTimes(x, y)),
      ),
    minVideo: this.streamVideoList$
      .pipe(
        tap(() => console.log('mixMaxVideos$ observable')),
        min((x, y) => this.compareVideoTimes(x, y)),
      ),
    averageVideoTime: this.streamVideoList$.pipe(
      tap(() => console.log('averageVideoTime$ observable')),
      averageVideoTime((acc: number, videoTime: VideoTime) => acc + this.convertTotalSeconds(videoTime.time)),
    ) 
});

forkJoin is optional here. I only use it to wait for all observables to complete and subsequently display the results on browser.

total: this.streamVideoList$
      .pipe(
         tap(() => console.log('videoTotal$ observable')),
         reduce((acc, videoTime) => acc + this.convertToTotalSeconds(videoTime.time), 0)
      )

reduces video times to a single total. Don’t confuse reduce with scan; the former emits one accumulated value whereas the latter emits accumulated value after each video time.

maxVideo and minVideo properties are similar. They pass the same comparison function to max and min operators to find the longest and shortest video respectively.

maxVideo: this.streamVideoList$
      .pipe(
        tap(() => console.log('mixMaxVideos$ observable')),
        max((x, y) => this.compareVideoTimes(x, y)),
      )
    
minVideo: this.streamVideoList$
      .pipe(
        tap(() => console.log('mixMaxVideos$ observable')),
        min((x, y) => this.compareVideoTimes(x, y)),
      )

averageVideoTime uses accumulator to add all the video times and to divide by the number of videos to find the average. averageVideoTime is reusable because it delegates summation to accumulator and is responsible for counting and division only.

averageVideoTime: averageVideoTime: this.streamVideoList$.pipe(
      tap(() => console.log('averageVideoTime$ observable')),
      averageVideoTime((acc: number, videoTime: VideoTime) => acc + this.convertTotalSeconds(videoTime.time)),
    )

Render total seconds with pipe

formatTotalSeconds pipe is trivial and it prints total seconds into “x Hours y minutes z seconds”

@Pipe({
  name: 'formatTotalSeconds'
})
export class FormatTotalSecondsPipe implements PipeTransform {

  transform(totalSeconds: number): string {
    let secondsLeft = totalSeconds;

    const hours = Math.floor(secondsLeft / 60 / 60);
    secondsLeft = secondsLeft % 3600;
    
    const minutes = Math.floor(secondsLeft / 60);
    secondsLeft = secondsLeft % 60;

    if (hours > 0) {
      return `${hours} Hours ${minutes} minutes ${secondsLeft} seconds`;
    }

    return `${minutes} minutes ${secondsLeft} seconds`;
  }
}

Final Thoughts

In this post, I show how to use RxJS and Angular to do a few things: make http call to request data from external source, convert array to a stream of video time (concatMap and from) and use various operators to manipulate video times. When devising the solution, I did my best not to manually subscribe observable. It was feasible by turning array to a stream and the operators receive the data to return results.

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/day18-adding-up-times
  2. Live demo: https://railsstudent.github.io/ng-rxjs-30/day18-adding-up-times/
  3. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30