Build a Music Player with Angular & Electron III : Bringing It All Together

Chris Nwamba

This post is the last part of "Build a Music Player with Angular and Electron." In the previous post we discussed presentation components which are also known as UI components. We were able to build out the UI for our app from start to finish but then users don't consume pretty UI. Users will always want behavior in as much as they appreciate a good looking app.

In this last post, we are going bring everything we have done together using a container component and abstracting data request and handling to injectable services.

As a quick reminder, the image shows what we are up to:

Injectable Services

Before we dive into building the container component, let's prepare the components' dependencies which are services that will handle few utility tasks including network requests and playing sounds with the browser's audio API.

API Service

The API service completes a very simple task -- makes a get request to Soundcloud depending all the url passed to it while attaching a client Id to the request:

// ./src/app/music/shared/api.service.ts
import { Injectable } from '@angular/core';
import { Http } from '@angular/http';

@Injectable()
export class ApiService {

    clientId = '[CLIENT_ID]'

    constructor(
      private http: Http
    ) {}

    get(url, attachClientId?) {
      // Should attach client id if the attachToken
      // is true
      let u;
      attachClientId ? u = this.prepareUrl(url) : u = url;
      // Returns an obsrevable
      // for the HTTP get request
      return this.http.get(u);
    }

    prepareUrl(url) {
      //Attach client id to stream url
      return `${url}?client_id=${this.clientId}`
    }

}

The prepareUrl method attaches the client Id to the URL so when a GET request is made, the get method checks if the attachClient flag is raised and calls prepareUrl based the flag condition.

Music Service

One more service we will need is the service that talks to API Service as well as use the audio API to play songs based on the feedback form the API Service:

// ./src/app/music/shared/music.service.ts
import { Injectable } from '@angular/core';
import { ApiService } from './api.service';

import 'rxjs/add/operator/map';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';

@Injectable()
export class MusicService {

  audio;

  constructor(
    private apiService: ApiService
  ) {
    this.audio = new Audio();
  }

  load(url) {
    this.audio.src = this.apiService.prepareUrl(url);
    this.audio.load();
  }

  play(url) {
    this.load(url);
    this.audio.play()
  }

  getPlaylistTracks () {
      //Request for a playlist via Soundcloud using a client id
      return this.apiService.get('https://api.soundcloud.com/playlists/209262931', true)
        .map(res => res.json())
        .map(data => data.tracks);
  }

  randomTrack(tracks) {
    const trackLength = tracks.length;
    // Pick a random number
    const randomNumber = Math.floor((Math.random() * trackLength) + 1);
    // Return a random track
    return tracks[randomNumber];
  }

  formatTime(seconds) {
    let minutes:any = Math.floor(seconds / 60);
    minutes = (minutes >= 10) ? minutes : "0" + minutes;
    seconds = Math.floor(seconds % 60);
    seconds = (seconds >= 10) ? seconds : "0" + seconds;
    return minutes + ":" + seconds;
  }

  findTracks(value) {
    return this.apiService.get(`${this.apiService.prepareUrl('https://api.soundcloud.com/tracks')}&q=${value}`, false)
      .debounceTime(300)
      .distinctUntilChanged()
      .map(res => res.json())
  }

  xlArtwork(url) {
    return url.replace(/large/, 't500x500');
  }

}

Let's walk through what this service by discussing each of the methods.

First things first, when the service is initialized, the audio API is set up by instantiating it and setting the instance to audio property on the class.

The load and play methods call the audio API's load and play methods as well as passing in a URL to be loaded and played.

The app is expected to load a random song and play the song when the app starts. I created a playlist (which you can of course replace) and using getPlaylistTracks to fetch the playlist's tracks. randomTrack is used to shuffle these tracks and pick one from the tracks to play.

formatTime method prepares the time so it can be displayed by the progress elapsed and total properties while xlArtwork generates a large image of the track artwork. We will see where the artwork is useful later in this post.

Finally, the findTracks will be utilized by the search component to search for tracks. The interesting thing about this method is that the returned observable is operated on with debounceTime to wait for 300ms interval between each request and distinctUntilChange to not repeat a request for the same values. These are great performance strategies.

App Component (Container Component)

Now to the real meat. The app component will serve as our container component, and with time we will see how the numbers add up.

What Happens when App Starts?

As mentioned earlier, while building our services, the plan is to play a random track from a given playlist when the app loads. To achieve this, we need to utilize Angular's lifecycle hook, ngOnInit which is a lifecycle method called when the component is ready:

// ./src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { MusicService } from './music/shared/music.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit{

  tracks: any[] = [];

  constructor(
    private musicService: MusicService
  ){}

  ngOnInit() {
    this.musicService.getPlaylistTracks().subscribe(tracks => {
      this.tracks = tracks;
      this.handleRandom();
    });

    // On song end
    this.musicService.audio.onended = this.handleEnded.bind(this);
    // On play time update
    this.musicService.audio.ontimeupdate = this.handleTimeUpdate.bind(this);
  }
}

MusicService is the only service we are injecting because the APIService is already used by MusicService for whatever we need the API service for.

The AppComponent class must implement OnInit so as to make use of the ngOnInit hook. The hook uses music service's getPlaylistTracks to get a list of tracks, set the tracks property to the returned list, and call the handleRandom method which we are yet to create.

We also set up two events -- what happens when a song ends and when the playing time updates. The events are passed handlers which we are yet to create too.

Random Tracks

The handleRandom method being used in the hook above is what we will create now. This method plucks a random song from the tracks property and plays the plucked song:

// . . .
export class AppComponent implements OnInit {
  title;
  tracks: any[] = [];
  backgroundStyle;

  //. . .

  handleRandom() {
    // Pluck a song
    const randomTrack = this.musicService.randomTrack(this.tracks);
    // Play the plucked song
    this.musicService.play(randomTrack.stream_url)
    // Set the title property
    this.title = randomTrack.title;
    // Create a background based on the playing song
    this.backgroundStyle = this.composeBackgroundStyle(randomTrack.artwork_url)
  }
}

One other thing that the method does is to set the title property for DetailsComponent which is the UI component that displays the title of the playing song. The method also set's a backgroundStyle property which is dynamically updates the background image of the app with the playing track's URL. composeBackgroundStyle returns the style object based on the image URL:

 composeBackgroundStyle(url) {
      return {
        width: '100%',
        height: '600px',
        backgroundSize:'cover',
        backgroundImage: `linear-gradient(
      rgba(0, 0, 0, 0.7),
      rgba(0, 0, 0, 0.7)
    ),   url(${this.musicService.xlArtwork(url)})`
      }
  }

The style is set on the template using NgStyle directive:

<!-- ./src/app/app.component.html -->
<div [ngStyle]="backgroundStyle">
</div>

Progress with Audio Events

When the component was initialized, we did set up some events for playing time update and song end. The handlers for these events were not created, let's do that now:

// . . .
export class AppComponent implements OnInit{
  title;
  position;
  elapsed;
  duration;
  tracks: any[] = [];
  backgroundStyle;

  constructor(
    private musicService: MusicService
  ){}

  ngOnInit() {
    // . . .
    this.musicService.audio.onended = this.handleEnded.bind(this);
    this.musicService.audio.ontimeupdate = this.handleTimeUpdate.bind(this);
  }

  // . . .

  handleEnded(e) {
    this.handleRandom();
  }

  handleTimeUpdate(e) {
    const elapsed =  this.musicService.audio.currentTime;
    const duration =  this.musicService.audio.duration;
    this.position = elapsed / duration;
    this.elapsed = this.musicService.formatTime(elapsed);
    this.duration = this.musicService.formatTime(duration);
  }
}

onended event is handled by handleEnded. The handler is very simple; it just calls the handleRandom method to shuffle and pluck a song.

ontimeupdate is called at intervals when the song is playing. So it's the perfect event to hook in and update our progress bar as well as the elapsed and total play time. This is why we are updating the the position, elapsed and duration properties which are passed down to the ProgressComponent.

Search Component Events & Properties

The search component demands values for it's tracks property, and event handlers for it's query and update events. These properties and events were created in the previous article.

The query event handle will be called handleQuery and will set the SearchComponent's tracks property using AppComponents's filteredTracks:

// . . .
export class AppComponent implements OnInit{
  // . . .
  filteredTracks: any[] = [];

  constructor(
    private musicService: MusicService
  ){}

  // . . .

  handleQuery(payload) {
      this.musicService.findTracks(payload).subscribe(tracks => {
        this.filteredTracks = tracks;
      });
  }
}

The handleQuery method uses MusicService's findTracks to find tracks that match the text being entered in the search text box and then sets filteredTracks to the fetched tracks. filteredTracks is what is passed as the value of tracks to the SearchComponent.

The handleUpdate is called when the autocomplete's suggestion is clicked. The handler plays the selected item:

// . . .
export class AppComponent implements OnInit{
  // . . .
  filteredTracks: any[] = [];

  constructor(
    private musicService: MusicService
  ){}

  // . . .

  handleQuery(payload) {
      this.musicService.findTracks(payload).subscribe(tracks => {
        this.filteredTracks = tracks;
      });
  }
}

Player Events & Properties

The player UI component has more events than the rest of the components. It just has one property, paused which is a boolean to check if a song is paused or playing.

Player: Pause & Play

The pause and play action are controlled by one event, and the paused flag property is used to check the playing status of a song and act accordingly:

// . . .
export class AppComponent implements OnInit{
  // . . .
  paused = true;

  constructor(
    private musicService: MusicService
  ){}

  // . . .

  handlePausePlay() {
      if(this.musicService.audio.paused) {
        this.paused = true;
        this.musicService.audio.play()
      } else {
        this.paused = false;
        this.musicService.audio.pause()
      }
  }
}

The handler checks if the song is paused and plays the song. Otherwise the reverse is the case.

Player: Stop

The stop events resets the song to beginning:

// . . .
export class AppComponent implements OnInit{
  // . . .

  constructor(
    private musicService: MusicService
  ){}

  // . . .

  handleStop() {
    this.musicService.audio.pause();
    this.musicService.audio.currentTime = 0;
    this.paused = false;
  }
}

It's just a trick. We pause the song, reset the time to beginning (0) and turn the paused flag down.

Player: Backward & Forward

The backward and forward events are used to rewind or fast forward the song based on a given interval:

// . . .
export class AppComponent implements OnInit{
  // . . .

  constructor(
    private musicService: MusicService
  ){}

  // . . .

  handleBackward() {
    let elapsed =  this.musicService.audio.currentTime;
    console.log(elapsed);
    if(elapsed >= 5) {
      this.musicService.audio.currentTime = elapsed - 5;
    }
  }

  handleForward() {
    let elapsed =  this.musicService.audio.currentTime;
    const duration =  this.musicService.audio.duration;
    if(duration - elapsed >= 5) {
      this.musicService.audio.currentTime = elapsed + 5;
    }
  }
}

For backward, we first check if we are 5 seconds into the song before attempting to take it back 5 seconds and for forward we check if we have up to 5 seconds left to be played in the song before trying to push it forward.

Player: Random

The random event just calls the random handler we created earlier to manually pluck a random track and play.

App Component Template

The app component template assembles all the UI component while passing them their respective properties and event handlers. It is also wrapped by a div which takes the NgStyle directive for dynamic background images.

<!-- ./src/app/app.component.html -->
<div [ngStyle]="backgroundStyle">
  <music-search
    (query)="handleQuery($event)"
    (update)="handleUpdate($event)"
    [tracks]="filteredTracks"
  ></music-search>

  <music-details
    [title]="title"
  ></music-details>

  <music-player
    (random)="handleRandom($event)"
    (backward)="handleBackward()"
    (forward)="handleForward()"
    (pauseplay)="handlePausePlay()"
    (stop)="handleStop()"
    [paused]="paused"
  ></music-player>

  <music-progress
    [current]="position"
    [elapsed]="elapsed"
    [total]="duration"
  ></music-progress>

  <music-footer></music-footer>

</div>

Conclusion

There are two important things to take home from this long journey -- how components interact and code reuse. We were able to divide and conquer by using different levels of component which come together to make an awesome product.

You must have noticed how many times a member of the music or API service got called and how many times we called handleRandom. If not for good structure which is cheap to afford, we would have got ourselves in a deep mess and probably given up on the just completed journey.

Chris Nwamba

44 posts

JavaScript Preacher. Building the web with the JS community.