Search code examples
angularhtml5-videoionic4ionic-native

Angular-Ionic: seamless transition between videos in video tag?


I have an Angular/Ionic (versions 10 and 5 respectively)component that gets a playlist in the form of an array. Each position is an object containing some metadata about the video and a fileSrc that targets the location of said video in my app's storage. In my template, I have a tag like this:

 <video *ngIf="video" #videoElement controls (ended)="skipToNextVideoInQueue()"
            [src]="video" type="video/mp4"

The component has a queuePosition public variable that, starting from 0, sets the current position of the playlist. When the video ends, the skipToNextVideoInQueue() method is fired. This method, among other things, does the following:

this.queuePosition += 1;
this.video = this.playlist[queuePosition].fileSrc;
this.playVideo();

And this last playVideo() method, in turn, merely does the following:

setTimeout(() => {
      const videoElement = this.videoElement.nativeElement;
      if (currentVideo) {
        currentVideo.play();
      }
    });

So far, so acceptable: the behavior is what I initially expected, more or less. The playlist starts from the beginning and keeps playing the next video on and on until the playlist comes to an end.

The only problem now is that, between the (ended) event of one video and the actual beginning of the next (the moment when I can see active frames) I get a brief loading screen. The ideal case would be for one video to transition as seamlessly as possible into the next one, without that skip or loading being noticed. I believe this screen is just the natural aspect of the tag when the src attribute is not fully loaded, but if it helps, I'll edit the thread to add a screenshot.

My guess is that the updating of the [src] attribute of the video is taking a while to be injected and that gives the fraction of a second loading screen that I mentioned.

Stuff I tried so far:

  • Verifying that my video is, in fact, loading from storage and not trying to access an external URL (and it is coming from storage).
  • Trying to create a dynamic list of tags with each one using the file src of the corresponding video in the loop, with only the video whose turn it is to be played in a display:block state. Like this:
<span *ngFor="let video of playlist; let i = index">
              <video *ngIf="video" #videoElement controls (ended)="skipToNextVideoInQueue()"
                [style.display]="queuePosition === index ? 'block' : 'none' ">
                <source [src]="video.fileSrc" type="video/mp4">
              </video>
</span>

The attempt above throws this error (where I just add some placeholders in this thread to keep some data about my app private):

GET http://192.168.1.45:8100/_capacitor_file_/data/user/0/{{app_name}}/files/{{fileName}}.mp4 net::ERR_FAILED

Solution

  • Well, bad news- it's not an elegant solution. Good news is that it can be done.

    [Note that this answer is edited on september 10th, '21 after I discovered a few flaws in my original answer]

    The main idea is to start loading the next video as soon as the first one starts playing. As soon as one video ends, we set its display value to none (we could even remove it completely by doing a .remove() method on the node), and we begin playing the next one automatically.

    Although I want to try and optimize this, what I've done is generate an N number of tags within an ngFor, based on a playlist object which in my case comes from a service.

    The template uses this:

    <ng-container *ngFor="let item of playlist; let i = index">
       <video #videoElements
          id="{{'videoPlayer' + i}}"
          (playing)="preloadNextVideo()"
          (ended)="skipToNextVideoInQueue()"
          preload="auto"
          type="video/mp4">
        </video>
    </ng-container>
    

    The approach needs the following to work in the controller:

    import { Component, ViewChildren, ElementRef, QueryList } from '@angular/core';
    
    @ViewChildren('videoElements') videoElements: QueryList<ElementRef>;
    
    public videoPlayers = [];
    public queuePosition: number;
    public playlist: any;
    constructor() {}
    
    ngOnInit() {
      this.queuePosition = 0;
      // the line below is a bit of pseudocode to show we need to get the playlist first
      this.myService.getPlaylist.then((result) => {
      this.playlist = result;
      this.storePlayerRefs();
      this.preloadVideo(0);
    }
    });
    
    // Allow the controller to locate the different children by index
      storePlayerRefs() {
        setTimeout(() => {
          this.videoPlayers = this.videoElements.toArray();
        });
      }
    
    
    
        preloadVideo(position: number) {
        setTimeout(() => {
          if (position < this.playlist.length) {
            const videoToPreload = this.videoPlayers[position];
            const sourceTag = document.createElement('source');
            // The line below assigns a timestamp to the URL so, in case of refreshes, the source tag will completely reset itself
            const fileSrc = this.playlist[position].fileSrc + '?t='+Math.random();
            sourceTag.setAttribute('src', fileSrc);
            sourceTag.setAttribute('type', 'video/mp4');
            videoToPreload.nativeElement.appendChild(sourceTag);
          }
        });
      }
    

    So far, what's going on is:

    • The videoElements queryList starts 'watching' the ngFor-generated video tags. After getting the playlist, since a queryList usually comes as an object of objects, I chose to save them in a videoPlayers variable as an array, since it made switching between videos easier.
    • The preloadVideo method finds the corresponding source of the playlist, creates a source node, sets the src attribute and attaches it. Since the html has the preload="auto" selector, we don't need to call the .load() method.

    After this initial setup is done, we enter the complex part (this is aso where it got interesting for me while developing this first attempt):

      playVideo() {
        setTimeout(() => {
          const videoElement = 
          this.videoPlayers[this.queuePosition].nativeElement;
          if (videoElement) {
            this.playing = true;
            this.hidePreviousPlayer();
            videoElement.style.display = 'block';
            videoElement.play();
          }
        });
      }
    

    This method, on its first call, can be invoked manually, after the success of another method... Depending on your needs. And when the video comes to and end, the ended event is fired, and we invoke this:

      skipToNextVideoInQueue() {
        if ((this.queuePosition + 1) < this.playlist.length) {
          this.queuePosition ++;
          this.playVideo();
          }
       } else {
       // handle whatever you need to do when the playlist is completed
    } 
    

    Last but not least, the playVideo() invoked this one as well before playing the current element:

      hidePreviousPlayer() {
        if (this.queuePosition > 0) {
          setTimeout(() => {
        const videoTodelete = this.videoPlayers[this.queuePosition - 1].nativeElement;
        videoTodelete.style.display = 'none';
        videoTarget.children[0].src = '';
        videoTarget.innerHTML = '';
        videoTarget.load();
          });
        }
      }
    

    So the logic behind all of this, is as follows:

    • The queuePosition variable keeps track of the video that has to be played at that time. The maximum value it can have is tied to the playlist length, and using that queuePosition as index, at any given time we can know what file needs to be accessed, and what Children has to be selected.
    • Preloading the i + 1 video at any given time helps us get rid of that small 'delay' that happens while the src attribute is loading and buffering the actual media it needs to play.
    • After a video has ended, we increase the value of queuePosition by one, so that the preloading of the video will always be one step ahead of the current one. That way, when the videoElement.play() method is fired, the information is already preloaded and ready to go.
    • Before playing the next element, the hidePreviousPlayer() method locates the i - 1 video (aka the previous one in the queue) sets its display to none and completely clears the source tag before removing it. This helps us optimize the app memory by keeping as few buffers active as possible.
    • With the information of the next video preloaded, with no controls tag in the video and the preload="auto" in the playlist, the transition is seamless.

    After typing all of this... I will be the first to admit that this isn't optimal. The refactor of the hidePreviousPlayer method definitely helped avoid memory issues, but this approach still involves generating N with one per element of the playlist.

    To-do: A second attempt using only two video tags and switching/preloading between them to reduce the amount of information that the DOM and the app has to handle.