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.
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 { forkJoin, of, mergeAll, 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')),
mergeAll(),
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:
- Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day18-adding-up-times
- Live demo: https://railsstudent.github.io/ng-rxjs-30/day18-adding-up-times/
- Wes Bos's JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30