How Angular’s Computed Properties Help Skip Titles

Russian-language documentation on Angular and popular plugin for refactoring Angular components.

My colleague Alexey Melnikov already told about the feature of skipping credits in KION, about its business and tech components. I will focus on what problems we had in the process of implementing the feature and how we solved them using Computed Properties in Angular*.

A little clarification about Computed Properties in Angular

At the very beginning, I’ll clarify that there are no Computed Properties in Angular itself, there is something similar in RxJS, which comes with it.

Angular is alive

Yes, you read that right: the kion.ru website and SmartTV app (Samsung, LG) are written in Angular. Why is Angular a good choice for SmartTV? This topic deserves a separate post.

And now I propose to stop opening these sections with spoilers and go to the article 🙂

Let me remind you what is skipping credits in KION. This feature saves time and allows you to minimize distractions from viewing. It is especially relevant for series, where the screensaver is often repeated from series to series, and the titles are completely the same. And (let’s be honest) no one usually watches them to the end, the audience just turns on the next episode.

It would seem that all that is needed to implement the feature is to send timestamps with captions on them, and simply show the buttons for skipping captions. But it was not there 🙂

So, let’s try to solve the problem clumsily. We make two “skip” buttons – for the initial and final credits.

Implementation “on the forehead”

Let’s imagine that we have the entity player (directly plays the movie) and player-ui (aggregates all the UI components of the player).

At the very beginning, we subscribe to player state changes in ngAfterViewInit:

@Component({
   selector: 'lib-player-ui',
   templateUrl: './player-ui.component.html',
   styleUrls: ['./player-ui.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayerUIComponent {
   // Здесь подписываемся на события плеера
   ngAfterViewInit(): void {
       this.player.registerStateChangeHandler((event: EventInfo) => {
           switch (event.state) {
               case ListenerEnums.timeupdate:
                   // Событие приходит в процессе проигрывания видео
                   break;
               case ListenerEnums.seeking:
                   // Событие приходит при перемотке видео
                   break;
               case ListenerEnums.ended:
                   // Событие приходит когда данное видео закончилось
                   // либо когда мы переключились на другое видео
                   break;
               default:
                   break;
           }
       });
   }
}

So far everything looks simple and obvious. Add a button to skip the end credits. Let’s show it when the timeupdate event arrives (when we watch a movie), hide it on the seeking (comes when we miss a certain period of time) and ended (when we have finished watching) events. Let’s call this button SkipTail.

@Component({
   selector: 'lib-player-ui',
   templateUrl: './player-ui.component.html',
   styleUrls: ['./player-ui.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayerSmartVodComponent {
   // Здесь подписываемся на события плеера
   ngAfterViewInit(): void {
       this.player.registerStateChangeHandler((event: EventInfo) => {
           switch (event.state) {
               case ListenerEnums.timeupdate:
                   const currentChapter = this.durationSeconds[Math.ceil(+event.currentTime)];
                   this.handleChapter(currentChapter);
                   break;
               case ListenerEnums.seeking:
                   this.clearChapter();
                   break;
               case ListenerEnums.ended: {
                   this.clearChapter();
                   break;
               }
               default:
                   break;
           }
       });
   }
   // проверяем есть ли информация о титрах (MovieChapter)
   private handleChapter(chapter: MovieChapter): void {
       switch (chapter?.title) {
           case ChapterTitleEnum.TAIL_CREDIT:
               this.showSkipTailButton();
               break;
       }
   }
   // прячем кнопку
   private clearChapter(): void {
       this.isShowSkipTail = false;
   }
   // показываем кнопку пропуска финальных титров
   private showSkipTailButton(): void {
       this.isShowSkipTail = true;
   }
}

Everything seems to be consistent and logical, although an experienced engineer already feels Code Smell here (but more on that later). Now let’s add the last missing element – the button to skip the opening credits SkipHead:

  // проверяем есть ли информация о титрах (MovieChapter)
   private handleChapter(chapter: MovieChapter): void {
       switch (chapter?.title) {
           case ChapterTitleEnum.HEAD_CREDIT:
               this.showSkipHeadButton();
               break;
           case ChapterTitleEnum.TAIL_CREDIT:
               this.showSkipTailButton();
               break;
       }
   }
   // прячем кнопку
   private clearChapter(): void {
       this.isShowSkipHead = false;
       this.isShowSkipTail = false;
   }
   // показываем кнопку пропуска начальных титров
   private showSkipHeadButton(): void {
       this.isShowSkipHead = true;
   }
   // показываем кнопку пропуска финальных титров
   private showSkipTailButton(): void {
       this.isShowSkipTail = true;
   }

And that’s it! You can safely give the code for testing. And there the problems that prompted me to write this article will just be revealed.

What are we facing

There are several problems here. Let’s start with the simplest one – the code begins to acquire “nuances” very sharply. The user can rewind from the initial credits to the final ones, as a result we will have 2 buttons. So let’s call clearChapter before showing any button:

case ListenerEnums.timeupdate:
    this.clearChapter();
    const currentChapter = this.durationSeconds[Math.ceil(+event.currentTime)];
    this.handleChapter(currentChapter);
    break;

And now we learn another nuance. The seeking event, which occurs at the time of rewind, may arrive earlier than the timeupdate event. This will cause us to first show the button for a fraction of a second and then hide it. We also have many other features that are somehow related to ours. This results in a combinatorial explosion of if/else and flags.

And we got into this situation, performing completely logical and consistent actions. Quite a few articles have been written about this problem, for example, These ones.

What are the options

Usually the problem is solved by moving away from component development towards StateManagers. There are Selectors that allow you to get a complex / combined state. But classic StateManagers are not very well optimized for very performance-critical applications. Readers will probably want to challenge this statement, since there is no such environment for JS in which StateManagers slow down. Alas, the webOS (LG) and Tizen (Samsung) platforms are unfortunate exceptions. We will definitely discuss the performance of JS on TVs, but in a separate article.

In addition to performance, we have another limitation – the existing codebase, which is not so easy to rewrite. So for now, let’s close the question with State Managers and return to the problem. Let’s try to solve it locally, without rewriting the entire codebase.

The articles above suggest solutions from the OOP world. But I want to talk about one solution from the world of functional programming, namely Reactive Programming or rather Computed Properties

Reactivity is a way to automatically update the system depending on the change in the data flow. A data stream is any sequence of events from any source, ordered in time.

Let’s take a simple example:

let A0 = 1
let A1 = 2
let A2 = A0 + A1
 
console.log(A2) // 3
 
A0 = 2
console.log(A2) // Все еще 3 :/

When we change A0, the value of A2 does not change automatically. We can get around this problem in frameworks like VueJS by using special ref, computed primitives.

import { ref, computed } from 'vue'
 
const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)
 
A0.value = 2

This code makes sure that when A0 changes, we will automatically update A2. Is there something similar in Angular? Unfortunately, the framework itself does not support Computed Properties out of the box. But Angular has RxJS!

const A0$ = new BehaviorSubject('Larry');
const A1$ = new BehaviorSubject('Wachowski');
const A2$ = combineLatest(
  A0$,
  A1$,
  ([A0_val, A1_val]) => A0_val + A1_val
);
A0$.next(2);

By rewriting the code in this way, we can get a cleaner and more understandable logic for showing the skip caption buttons.

const isShowSkipHead$ = combineLatest(
   time$,
   chapters$,
   isSeeking$,
   (time, chapters, isSeeking) => {
       if (isSeeking) return false;
      
       const currentTime = Math.ceil(time / 1000);
       const currentChapter = chapters[currentTime];
       if (currentChapter && currentChapter.title === ChapterTitleEnum.HEAD_CREDIT) {
           return true;
       }
  
       return false;
   }
);

And in the code using async pipe, you can use Observable data:

[isShowSkipHead]="isShowSkipHead$ | async"

What other options are there?

As I said above, Angular does not support computed properties out of the box. The authors of the framework are already working on this, but so far the status is under consideration.

https://github.com/angular/angular/issues/20472

https://github.com/angular/angular/issues/43485

The most obvious option is to simply write a method in the body of our component and call it in the template:

isShowSkipHead(): boolean {
   const currentTime = Math.ceil(this.currentTime / 1000);
   const currentChapter = this.durationSeconds[currentTime];
   if (currentChapter && currentChapter.title === ChapterTitleEnum.HEAD_CREDIT) {
       return true;
   }
   return false;
}

But this is a very bad practice, as it leads to significant degraded application performance.

We can emulate Computed Properties code with Angular Pipe:

import { Pipe, PipeTransform } from '@angular/core';
 
@Pipe({
 name: 'is-show-head'
})
export class isShowSkipHeadPipe implements PipeTransform {
 
 transform(time: any, chapters: any): any {
   const currentTime = Math.ceil(time / 1000);
   const currentChapter = chapters[currentTime];
   if (currentChapter && currentChapter.title === ChapterTitleEnum.HEAD_CREDIT) {
       return true;
   }
   return false;
 }
}

Or we can manually calculate the value for each ngOnChanges:

ngOnChanges(changes: SimpleChanges) {
   if (changes.time || changes.chapter) {
       this.isShowSkipHead = this.calculateIsShowSkipHead();
   }
}

There are also craftsmen who use VueJS primitives right in Angular 😀

Instead of conclusions

We didn’t go down one of the above alternative paths, didn’t start rewriting everything in Redux/Mobx/Akita, but chose the RxJS approach. Alas, I will not be able to show the main reason for this decision. Simply because there are a lot of different conditions and events, and in order to demonstrate them, you will have to show a large piece of the code base.

In short, the RxJS approach allows us to separate business logic into separate atomic and logical pieces, combine them in any order, while maintaining code cleanliness. With its help, we were able to rewrite a complex application module without changing the logic of the entire application and its other parts. And this way you can reduce development time and remove annoying bugs caused by combinatorial explosion.

To understand Reactive Programming using Observable, I advise you to look here This Video (carefully, a lot of computer science!), RxJS parsing and this report.

That’s all. I hope that our experience is useful to you and you are interested in reactive programming and RxJS. And if you already have something to tell on these topics do it in the comments! Questions are waiting there.

Similar Posts

Leave a Reply