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:
Table of Contents
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.
BeginnerTailwind.com Learn Tailwind CSS from ScratchFirst 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.
Like this article? Follow @codebeast on Twitter