- Introduction
- What we'll be creating
- What we'll need
- Setting up our app
- Appropriate window
- Styling the window
- Let's fix more styles
- Creating actions and a store
- Building the play list
- Creating the drop area
- Adding files to the list
- Playing a song
- Adding more controls
- Finishing the store
- Fixing the styles for the seeker
- Adding Cover
- Player Spectrum
- Adding the gooey effect
- Build
I'm here today to teach you how to build a electron music player, if you follow my steps you should get a cool looking mp3 player based on Lucas Bebber gooey example posted on codrops.
I'll be providing some music created by my friend Fábio Guedes
Check his work on Spotify and here's a complete album.
All the provided icons used in this app were taken from flat icon and they were all made by Madebyoliver
Please check the dependencies list to check where they came from.
PS: We won't care about windows for this tutorial. We'll be focused in getting it to work on OSX, but it should be the same for windows.
Go ahead and download or clone Electron React Boilerplate
Change the package name and all the related information to the product.
Now we'll remove unnecessary dependencies
// Found in devDependencies
"redux-logger": "^2.6.1",
// Found in dependencies
"react-redux": "^4.4.5",
"react-router": "^2.6.0",
"react-router-redux": "^4.0.5",
"redux": "^3.5.2",
"redux-thunk": "^2.1.0",
Here's how the package.json should look
Locate the app folder and remove all files and folders, leaving only
- app.html
- app.icns
- app.global.css
- index.js
Here's what you should endup with
Locate app/index.js
and clean it up as follows
import React from 'react';
import { render } from 'react-dom';
import './app.global.css';
render(
<div>
<h1>Hello world</h1>
</div>,
document.getElementById('root')
);
Now we are ready to test the app and see if everything went fine
On your terminal run
npm install
When that's done execute
npm run dev
If everything goes well you should see this
Ignore the error in the console, everything works fines
If you remember, the window that we used for the project seems customized
Let's do it now
Open main.development.js
Locate app.on('ready', async () => {
In that block of code you can see a mainWindow
variable
Replace it with
mainWindow = new BrowserWindow({
show: false,
width: 318,
height: 500,
frame: false,
resizable: process.env.NODE_ENV === 'development'
});
Save the file and if you don't see any changes rerun npm run dev
from the command line
Since the window is too small we don't want to open the dev tools by default, but we still want to access it if in development.
Locate the line that says mainWindow.openDevTools();
and remove it.
From now on if we want to access the dev tools we just need to right click on the page and select inspect element.
There were some other changes made in main.development.js
please check the diff file to see them. They relate to changes in the menu and tray
Your window should look as the image above, obviously, since we have no title bar we can no longer drag it. Let's start styling it and add a dragging area to it.
To avoid errors down the line we'll need to install a webpack image loader
npm i image-webpack-loader -D
And on webpack.config.base.js
we'll add
{
test: /\.(jpe?g|png|gif|svg)$/i,
loaders: [
'file?hash=sha512&digest=hex&name=[hash].[ext]',
'image-webpack?bypassOnDebug&optimizationLevel=10&interlaced=false'
]
}
This way all our css will load the images correctly
Let's create our first component app/components/Home.js
import React, { Component } from 'react';
import styles from './Home.css';
export default class Home extends Component {
constructor() {
super();
this.onCloseClick = this.onCloseClick.bind(this);
this.onMenuClick = this.onMenuClick.bind(this);
}
onMenuClick() {
alert('MENU');
}
onCloseClick() {
alert('CLOSE');
}
render() {
let className = styles.homeContainer;
return (
<div className={ className }>
<div className={ styles.playerTitle }>
<div className={ styles.closeButton } onClick={this.onCloseClick}></div>
<div className={ styles.menuButton } onClick={this.onMenuClick}></div>
</div>
</div>
);
}
}
Please note that we don't have a Home.css
yet, let's create one at app/components/Home.css
.homeContainer {
-webkit-filter: blur(0);
filter: blur(0);
-webkit-transition: all 0.3s;
-moz-transition: all 0.3s;
transition: all 0.3s;
}
.fileListOpened {
-webkit-filter: blur(3px);
filter: blur(3px);
}
.filesOver {
-webkit-filter: blur(10px);
filter: blur(10px);
}
.playerTitle {
height: 105px;
background: #3a3a3a;
-webkit-user-select: none;
-webkit-app-region: drag;
}
.menuButton {
position: absolute;
left: 10px;
top: 10px;
background: url("../assets/img/menu.svg");
background-size: 100%;
cursor: pointer;
width: 32px;
height: 32px;
-webkit-app-region: no-drag;
}
.closeButton {
position: absolute;
right: 10px;
top: 10px;
background: url("../assets/img/close.svg");
background-size: 100%;
cursor: pointer;
width: 32px;
height: 32px;
-webkit-app-region: no-drag;
}
We now need to update the index.js
import React from 'react';
import { render } from 'react-dom';
import Home from './components/Home';
import './app.global.css';
render(
<Home />,
document.getElementById('root')
);
At this point if we re-run the app we should see something like
The app is draggable now from the gray area. And when the buttons are clicked an alert should pop with CLOSE
or MENU
written on it.
If you encountered any errors please double check that the path to images is correct and that the webpack.config.base.js contains the right loaders
For starters let's remove the border around the gray area
On app/app.global.css
let's replace all it's contents with
body {
position: relative;
color: white;
height: 100vh;
background-color: #222222;
padding: 0;
margin: 0;
font-family: sans-serif;
}
Time to get those buttons to work.
Create a new file app/dispatcher/index.js
import { Dispatcher } from 'flux';
class ApplicationDispatcher extends Dispatcher {
}
export default new ApplicationDispatcher();
That's all we need but we need to install flux, so from the command line type in npm i flux -D
npm i flux -D
translates to npm install flux --save-dev
Let's generate the store, create a new file app/store/index.js
import Dispatcher from '../dispatcher';
import { remote } from 'electron';
import EventEmitter from 'events';
class Store extends EventEmitter {
closeCurrentWindow() {
remote.getCurrentWindow().close();
}
openListMenu() {
alert('Open List menu');
}
}
const store = new Store();
Dispatcher.register((action) => {
switch (action.actionType) {
default:
break;
}
});
export default store;
You can tell that we are handling the close button already but it won't work just yet.
We need events and an action to trigger those events
Create a new file app/events/index.js
export const CLOSE_WINDOW = 'CLOSE_WINDOW';
export const OPEN_LIST = 'OPEN_LIST';
export const CLOSE_LIST = 'CLOSE_LIST';
One more file... app/actions/home.js
import Dispatcher from '../Dispatcher';
import { CLOSE_WINDOW, OPEN_LIST } from '../events';
export default {
closeWindow: () => {
Dispatcher.dispatch({
actionType: CLOSE_WINDOW
});
},
openList: () => {
Dispatcher.dispatch({
actionType: OPEN_LIST
});
}
};
All these changes and it still doesn't work right?
Let's fix that then
Open app/components/Home.js
and let's start
Import the home actions and call it in the close button
import HomeActions from '../actions/home';
...
onMenuClick() {
HomeActions.openList();
}
onCloseClick() {
HomeActions.closeWindow();
}
...
Now let's open app/store/index
Where we register the Dispatcher events let's make sure it looks like the following
Dispatcher.register((action) => {
switch (action.actionType) {
case CLOSE_WINDOW:
store.closeCurrentWindow();
break;
case OPEN_LIST:
store.openListMenu();
break;
default:
break;
}
});
Also we need to make sure that we're importing those events
import { CLOSE_WINDOW, OPEN_LIST } from '../events';
Nothing will happen if we run the app now because the store is not being imported anywhere,
so let's open app/index.js
and import the store.
import React from 'react';
import { render } from 'react-dom';
import Home from './components/Home';
import './app.global.css';
import './store';
render(
<Home />,
document.getElementById('root')
);
If everything went fine you should be able to click the close button and the window will close. Also if you click on the list button you'll see a new alert saying "Open List menu"
If something went wrong you won't be seeing any content on the page, make sure you check any potential erros on the console.
Let's start by adding our new component to app/index.js
import React from 'react';
import { render } from 'react-dom';
import Home from './components/Home';
import List from './components/List';
import './app.global.css';
import './store';
render(
<div>
<Home />
<List />
</div>,
document.getElementById('root')
);
Let's add more events to app/events/index.js
export const CLOSE_WINDOW = 'CLOSE_WINDOW';
export const OPEN_LIST = 'OPEN_LIST';
export const CLOSE_LIST = 'CLOSE_LIST';
export const REORDER_LIST = 'REORDER_LIST';
export const DRAGGING_FILES = 'DRAGGING_FILES';
export const NOT_DRAGGING_FILES = 'NOT_DRAGGING_FILES';
export const DROP_FILES = 'DROP_FILES';
export const REMOVE_FROM_LIST = 'REMOVE_FROM_LIST';
export const LIST_UPDATE = 'LIST_UPDATE';
export const STARTED_PLAYING = 'STARTED_PLAYING';
export const STOPPED_PLAYING = 'STOPPED_PLAYING';
export const RESTART_SONG = 'RESTART_SONG';
export const PLAY_SONG = 'PLAY_SONG';
export const PAUSE_SONG = 'PAUSE_SONG';
export const PLAY_NEXT_SONG = 'PLAY_NEXT_SONG';
export const PLAY_PREVIOUS_SONG = 'PLAY_PREVIOUS_SONG';
export const MUTE_SOUND = 'MUTE_SOUND';
export const UNMUTE_SOUND = 'UNMUTE_SOUND';
export const SET_VOLUME = 'SET_VOLUME';
export const SEEK_SONG = 'SEEK_SONG';
export const ANIMATION_FRAME = 'ANIMATION_FRAME'; // We'll use this at the very end of the tutorial (All the other events are self explanatory)
Those are all the events that we need to finish the app so we can close that file forever
Let's create a couple of more actions that will control the list and future events like play, pause, next song, previous song
Let's create app/actions/list.js
import Dispatcher from '../dispatcher';
import { CLOSE_LIST, REORDER_LIST, REMOVE_FROM_LIST } from '../events';
export default {
closeList: () => {
Dispatcher.dispatch({
actionType: CLOSE_LIST
});
},
reorderedList: (newList) => {
Dispatcher.dispatch({
actionType: REORDER_LIST,
list: newList
});
},
removeFromList: (ids) => {
Dispatcher.dispatch({
actionType: REMOVE_FROM_LIST,
ids
});
}
};
Let's create another file app/actions/music.js
import Dispatcher from '../dispatcher';
import {
PLAY_SONG,
PAUSE_SONG,
PLAY_NEXT_SONG,
PLAY_PREVIOUS_SONG,
SEEK_SONG,
MUTE_SOUND,
UNMUTE_SOUND,
SET_VOLUME,
RESTART_SONG
} from '../events';
export default {
playSong: (song) => {
Dispatcher.dispatch({
actionType: PLAY_SONG,
song
});
},
pauseSong: (song) => {
Dispatcher.dispatch({
actionType: PAUSE_SONG,
song
});
},
mute: () => {
Dispatcher.dispatch({
actionType: MUTE_SOUND
});
},
unmute: (savedVolume = 100) => {
Dispatcher.dispatch({
actionType: UNMUTE_SOUND,
volume: savedVolume
});
},
setVolume: (volume) => {
Dispatcher.dispatch({
actionType: SET_VOLUME,
volume
});
},
restartSong: () => {
Dispatcher.dispatch({
actionType: RESTART_SONG
});
},
playPreviousSong: () => {
Dispatcher.dispatch({
actionType: PLAY_PREVIOUS_SONG
});
},
playNextSong: () => {
Dispatcher.dispatch({
actionType: PLAY_NEXT_SONG
});
},
seek: (value) => {
Dispatcher.dispatch({
actionType: SEEK_SONG,
value
});
}
};
Now that we have our actions, let's start with creating a SortableList
component
let's create it app/components/SortableList.js
import React, { Component } from 'react';
import store from '../store';
import styles from './SortableList.css';
import SortableListItem from './SortableListItem';
import {
DRAGGING_FILES,
NOT_DRAGGING_FILES
} from '../events';
class SortableList extends Component {
constructor(props) {
super();
this.state = {
draggingIndex: null,
data: props.data,
draggingFilesOver: false
};
this.onStateUpdate = this.onStateUpdate.bind(this);
this.onDragFilesStart = this.onDragFilesStart.bind(this);
this.onDragFilesStop = this.onDragFilesStop.bind(this);
}
componentDidMount() {
store.on(DRAGGING_FILES, this.onDragFilesStart);
store.on(NOT_DRAGGING_FILES, this.onDragFilesStop);
}
componentWillUnmount() {
store.removeListener(DRAGGING_FILES, this.onDragFilesStart);
store.removeListener(NOT_DRAGGING_FILES, this.onDragFilesStop);
}
componentWillReceiveProps(newProps) {
this.setState({
data: newProps.data
});
}
onStateUpdate(obj) {
this.setState(obj);
if (obj.data) {
this.onComponentFinishedUpdating(obj.data);
}
}
onComponentFinishedUpdating(data) {
if (this.props.onListReorder) {
this.props.onListReorder.call(null, data);
}
}
onDragFilesStart() {
this.setState({
draggingFilesOver: true
});
}
onDragFilesStop() {
this.setState({
draggingFilesOver: false
});
}
render() {
let className = styles.container;
if (this.state.draggingFilesOver) {
className += ` ${styles['has-filesOver']}`;
}
const listItems = this.state.data.map((item, i) => {
return (
<SortableListItem
key={ i }
updateState={ this.onStateUpdate }
items={ this.state.data }
draggingIndex={ this.state.draggingIndex }
sortId={ i }
outline="list"
>{{ // We need to add it to the children so that react-sortable passes it down to our component
className: i % 2 === 0 ? 'is-even' : 'is-odd',
onPlayButtonClick: this.props.onPlayButtonClick,
onPauseButtonClick: this.props.onPauseButtonClick,
onClick: this.props.toggleItem,
isPlaying: this.props.songPlaying && this.props.songPlaying.id === item.id,
isPaused: this.props.songPlaying && this.props.songPlaying.paused,
selected: this.props.selectedItems.indexOf(item.id) > -1,
items: this.state.data,
item
}}</SortableListItem>
);
}, this);
return (
<div className={ className }>{ listItems }</div>
);
}
}
export default SortableList;
Take a minute to look at the code, we're missing a component called SortableListItem
Let's add it app/components/SortableListItem.js
import React, { Component } from 'react';
import styles from './SortableListItem.css';
import { Sortable } from 'react-sortable';
class ListItem extends Component {
constructor(props) {
super();
this.onPlayButtonClickProxy = this.onPlayButtonClickProxy.bind(this);
this.onPauseButtonClickProxy = this.onPauseButtonClickProxy.bind(this);
this.onClickProxy = this.onClickProxy.bind(this);
}
onPlayButtonClickProxy(e) {
e.preventDefault();
e.stopPropagation();
const position = parseInt(e.target.parentNode.dataset.id, 10);
if (this.props.children.onPlayButtonClick) {
this.props.children.onPlayButtonClick.call(null, this.props.children.items[position]);
}
}
onPauseButtonClickProxy(e) {
e.preventDefault();
e.stopPropagation();
const position = parseInt(e.target.parentNode.dataset.id, 10);
if (this.props.children.onPauseButtonClick) {
this.props.children.onPauseButtonClick.call(null, this.props.children.items[position]);
}
}
onClickProxy(e) {
const position = parseInt(e.target.parentNode.dataset.id, 10);
if (this.props.children.onClick) {
this.props.children.onClick.call(null, this.props.children.items[position]);
}
}
render() {
let className = `${styles.item} ListItem__${this.props.children.className}`;
if (this.props.children.selected) {
className += ` ${styles['is-selected']}`;
}
if (this.props.children.isPlaying) {
className += ` ${styles['is-playing']}`;
}
return <div
data-id= { this.props['data-id'] }
draggable={ this.props.draggable }
onDragStart={ this.props.onDragStart }
onDragEnd={ this.props.onDragEnd }
onDragOver={ this.props.onDragOver }
onTouchStart={ this.props.onTouchStart }
onTouchMove={ this.props.onTouchMove }
onTouchEnd={ this.props.onTouchEnd }
onClick={ this.onClickProxy }
className={ className }
>
<span className={ styles.itemNumber }></span>
<span className={ styles.itemName }>{ this.props.children.item.displayName }</span>
{ !this.props.children.isPlaying || this.props.children.isPaused ?
<i className={ styles.playButton } onClick={ this.onPlayButtonClickProxy }></i> :
<i className={ styles.pauseButton } onClick={ this.onPauseButtonClickProxy }></i>
}
</div>;
}
}
export default Sortable(ListItem);
This component will take care of a single item on the list, but it requires another npm module called react-sortable
.
For more info on the module please refer to the github page
Let's add it by executing npm i react-sortable -S
from the command line
We've didn't add the styles for the components.
Let's add them now
app/components/SortableListItem.css
.item {
counter-increment: item-counter;
position: relative;
height: 60px;
-webkit-user-select: none;
}
.item:hover .itemNumber {
opacity: 0;
}
.item:hover .playButton {
opacity: 1;
}
.item.is-playing .itemNumber{
opacity: 0;
}
.item.is-playing .playButton{
opacity: 1;
}
.playButton {
opacity: 0;
position: absolute;
left: 10px;
top: 15px;
background: url("../assets/img/play.svg");
background-size: 100%;
cursor: pointer;
width: 32px;
height: 32px;
-webkit-app-region: no-drag;
-webkit-transition: all 0.3s;
-moz-transition: all 0.3s;
transition: all 0.3s;
}
.pauseButton {
position: absolute;
left: 10px;
top: 15px;
background: url("../assets/img/pause.svg");
background-size: 100%;
cursor: pointer;
width: 32px;
height: 32px;
-webkit-app-region: no-drag;
}
.itemNumber {
opacity: 1;
font-size: 18px;
position: absolute;
display: block;
left: 18px;
top: 0;
line-height: 60px;
color: #9d9d9d;
-webkit-transition: all 0.3s;
-moz-transition: all 0.3s;
transition: all 0.3s;
}
.itemNumber::before {
content: counter(item-counter) ".";
}
.itemName {
display: block;
width: 80%;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 60px;
text-indent: 50px;
font-size: 16px;
font-weight: normal;
overflow: hidden;
}
.is-selected {
background: rgba(255, 255, 255, 0.3) !important;
}
And another one for app/components/SortableList.css
.container {
position: absolute;
top: 105px;
bottom: 0;
left: 0;
right: 0;
overflow: auto;
counter-reset: item-counter;
-webkit-filter: blur(0);
filter: blur(0);
-webkit-transition: all 0.3s;
-moz-transition: all 0.3s;
transition: all 0.3s;
}
.has-filesOver {
-webkit-filter: blur(2px);
filter: blur(2px);
}
The SortableList
and SortableListItem
are now done, but we're still missing the List
component, but before jumping into that, let's register the events to the store.
Let's open app/store/index.js
// Replace the events that we had before with
import {
CLOSE_WINDOW,
OPEN_LIST,
DRAGGING_FILES,
NOT_DRAGGING_FILES
} from '../events';
...
// Let's register those events
Dispatcher.register((action) => {
switch (action.actionType) {
case DRAGGING_FILES:
store.emit(DRAGGING_FILES);
break;
case NOT_DRAGGING_FILES:
store.emit(NOT_DRAGGING_FILES);
break;
case CLOSE_WINDOW:
store.closeCurrentWindow();
break;
...
We're forcing the store to emit the same value, which means that other components will listen to those events and perform changes to their state.
If you take a look at the showcase gif again, you'lll notice that when the files get dragged over the player, the background elements blur, and that's what these 2 events (DRAGGING_FILES
and NOT_DRAGGING_FILES
) will helps achieve.
One example of this is the SortableList
Component.
Create a file app/components/List.js
import React, { Component } from 'react';
import store from '../store';
import ListActions from '../actions/list';
import MusicActions from '../actions/music';
import styles from './List.css';
import SortableList from './SortableList';
import {
OPEN_LIST,
CLOSE_LIST,
LIST_UPDATE,
STARTED_PLAYING,
STOPPED_PLAYING
} from '../events';
const KEYCODE_ESC = 27;
export default class List extends Component {
constructor() {
super();
this.onOpenList = this.onOpenList.bind(this);
this.onCloseList = this.onCloseList.bind(this);
this.onListUpdate = this.onListUpdate.bind(this);
this.onBackButtonClicked = this.onBackButtonClicked.bind(this);
this.onRubbishButtonClicked = this.onRubbishButtonClicked.bind(this);
this.onPlayButtonClick = this.onPlayButtonClick.bind(this);
this.onPauseButtonClick = this.onPauseButtonClick.bind(this);
this.onListReorder = this.onListReorder.bind(this);
this.onSongPlaying = this.onSongPlaying.bind(this);
this.onSongStopped = this.onSongStopped.bind(this);
this.toggleItem = this.toggleItem.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.state = {
opened: false,
list: store.getList(),
draggingIndex: null,
draggingFilesOver: false,
selectedItems: [],
songPlaying: null
};
}
componentDidMount() {
store.on(OPEN_LIST, this.onOpenList);
store.on(CLOSE_LIST, this.onCloseList);
store.on(LIST_UPDATE, this.onListUpdate);
store.on(STARTED_PLAYING, this.onSongPlaying);
store.on(STOPPED_PLAYING, this.onSongStopped);
document.addEventListener('keyup', this.onKeyUp);
}
componentWillUnmount() {
store.removeListener(OPEN_LIST, this.onOpenList);
store.removeListener(CLOSE_LIST, this.onCloseList);
store.removeListener(LIST_UPDATE, this.onListUpdate);
store.removeListener(STARTED_PLAYING, this.onSongPlaying);
store.removeListener(STOPPED_PLAYING, this.onSongStopped);
document.removeEventListener('keyup', this.onKeyUp);
}
onOpenList() {
this.setState({
opened: true
});
}
onCloseList() {
this.setState({
opened: false
});
}
onListUpdate() {
this.setState({
list: store.getList()
});
}
onSongPlaying() {
this.setState({
songPlaying: store.getSongPlaying()
});
}
onSongStopped() {
this.setState({
songPlaying: null
});
}
onKeyUp(e) {
if (this.state.opened && e.keyCode === KEYCODE_ESC) {
ListActions.closeList();
}
}
onBackButtonClicked() {
ListActions.closeList();
}
onRubbishButtonClicked() {
if (!this.state.selectedItems) {
return;
}
ListActions.removeFromList(this.state.selectedItems);
this.setState({
selectedItems: []
});
}
onPlayButtonClick(file) {
MusicActions.playSong(file);
}
onPauseButtonClick() {
MusicActions.pauseSong();
}
onListReorder(newList) {
ListActions.reorderedList(newList);
}
toggleItem(file) {
const selectedItems = this.state.selectedItems;
const index = selectedItems.indexOf(file.id);
if (index > -1) {
selectedItems.splice(index, 1);
} else {
selectedItems.push(file.id);
}
this.setState({
selectedItems
});
}
render() {
let className = 'menuList';
let rubbishButtonClassName = styles.rubbishButton;
if (this.state.opened) {
className += ' is-opened';
}
if (this.state.selectedItems.length) {
rubbishButtonClassName += ` ${styles['is-active']}`;
}
return (
<div className={ className }>
<div className={ styles.backButton } onClick={ this.onBackButtonClicked }></div>
<div className={ rubbishButtonClassName } onClick={ this.onRubbishButtonClicked }></div>
{ this.renderList() }
</div>
);
}
renderList() {
return (
<SortableList
onListReorder={ this.onListReorder }
onPlayButtonClick={ this.onPlayButtonClick }
onPauseButtonClick={ this.onPauseButtonClick }
toggleItem={ this.toggleItem }
selectedItems={ this.state.selectedItems }
songPlaying={ this.state.songPlaying }
data={ this.state.list }
/>
);
}
}
and style it with app/components/List.css
.backButton {
position: absolute;
left: 10px;
top: 10px;
background: url("../assets/img/back.svg");
background-size: 100%;
cursor: pointer;
width: 32px;
height: 32px;
-webkit-app-region: no-drag;
}
.rubbishButton {
position: absolute;
left: 52px;
top: 10px;
background: url("../assets/img/rubbish.svg");
background-size: 100%;
width: 32px;
height: 32px;
-webkit-app-region: no-drag;
opacity: 0.2;
-webkit-transition: all 0.3s;
-moz-transition: all 0.3s;
transition: all 0.3s;
}
.rubbishButton.is-active {
opacity: 1;
cursor: pointer;
}
At this point if we run the app an error will be thrown because store.getList() doesn't exist.
But let's fix that and see what we have so far
If we change the store and add a getList()
method the app will run
app/store/index.js
...
class Store extends EventEmitter {
constructor() {
super();
this.songsList = [];
}
closeCurrentWindow() {
remote.getCurrentWindow().close();
}
openListMenu() {
alert('Open List menu');
}
getList() {
return this.songsList;
}
}
...
This is what we'll see
Not very appealing I know, but we'll fix it soon enough
Let's keep on going and add more events to the store that will actually help us identifying the song that is playing and reorder the musics
We'll add all the events this time and all the needed methods to finish the player and we'll complete them as we go along
app/store/index.js
// Events needed
import {
CLOSE_WINDOW,
REORDER_LIST,
OPEN_LIST,
CLOSE_LIST,
DRAGGING_FILES,
NOT_DRAGGING_FILES,
DROP_FILES,
REMOVE_FROM_LIST,
LIST_UPDATE,
STARTED_PLAYING,
STOPPED_PLAYING,
PLAY_SONG,
PAUSE_SONG,
PLAY_NEXT_SONG,
PLAY_PREVIOUS_SONG,
RESTART_SONG,
SEEK_SONG,
MUTE_SOUND,
UNMUTE_SOUND,
SET_VOLUME,
ANIMATION_FRAME
} from '../events';
...
// All the incomplete methods contain a console log
class Store extends EventEmitter {
constructor() {
super();
this.songsList = [];
this.playingSong = null;
this.volume = 100;
this.isMute = false;
this.onSongFinished = this.onSongFinished.bind(this);
}
closeCurrentWindow() {
remote.getCurrentWindow().close();
}
openListMenu() {
this.emit(OPEN_LIST);
}
addToList(files) {
console.log('addToList: TODO');
}
removeFromList(ids) {
this.songsList = this.songsList.filter((item) => (
ids.indexOf(item.id) === -1
));
this.emit(LIST_UPDATE);
}
getList() {
return this.songsList;
}
replaceList(files) {
this.songsList = files;
this.emit(LIST_UPDATE);
}
getSongPlaying() {
return this.playingSong;
}
getVolume() {
return this.volume;
}
playSong(file) {
console.log('playSong TODO');
}
onSongFinished() {
this.playNextOnTheList();
}
playNextOnTheList() {
let i;
if (!this.songsList.length) {
return;
}
if (!this.playingSong) {
this.playSong(this.songsList[0]);
} else {
for (i = 0; i < this.songsList.length; i++) {
if (this.songsList[i].id === this.playingSong.id) {
if (i + 1 >= this.songsList.length) {
this.stopSong(this.playingSong);
} else {
this.playSong(this.songsList[i + 1]);
}
break;
}
}
}
}
playPreviousOnTheList() {
let i, songToPlay;
if (!this.songsList.length) {
return;
}
if (!this.playingSong) {
this.playSong(this.songsList[0]);
} else {
for (i = 0; i < this.songsList.length; i++) {
if (this.songsList[i].id === this.playingSong.id) {
songToPlay = this.songsList[i - 1];
if (i - 1 < 0) {
songToPlay = this.songsList[this.songsList.length - 1];
}
this.playSong(songToPlay);
break;
}
}
}
}
resumeSong() {
this.playingSong.paused = false;
console.log('resumeSong TODO');
this.emit(STARTED_PLAYING, this.playingSong);
}
pauseSong() {
this.playingSong.paused = true;
console.log('pauseSong TODO');
this.emit(STARTED_PLAYING, this.playingSong);
}
stopSong(file) {
if (!file) {
return;
}
this.emit(STOPPED_PLAYING, this.playingSong);
this.playingSong = null;
console.log('stopSong TODO');
}
restartSong() {
console.log('restartSong TODO');
}
isMuted() {
return this.isMute;
}
mute() {
this.isMute = true;
console.log('mute TODO');
this.emit(MUTE_SOUND);
}
unmute(volume) {
this.isMute = false;
console.log('unmute TODO');
this.emit(UNMUTE_SOUND);
}
setVolume(volume) {
console.log('setVolume TODO');
this.volume = volume;
this.emit(SET_VOLUME, volume);
}
getPlaytime() {
console.log('getPlaytime TODO');
}
getSongDuration() {
console.log('getSongDuration TODO');
}
seek(value) {
console.log('seek TODO');
}
getFrequency() {
console.log('getFrequency TODO');
}
}
...
// Register the events
Dispatcher.register((action) => {
switch (action.actionType) {
case DRAGGING_FILES:
store.emit(DRAGGING_FILES);
break;
case NOT_DRAGGING_FILES:
store.emit(NOT_DRAGGING_FILES);
break;
case CLOSE_WINDOW:
store.closeCurrentWindow();
break;
case OPEN_LIST:
store.openListMenu();
break;
case CLOSE_LIST:
store.emit(CLOSE_LIST);
break;
case REORDER_LIST:
store.replaceList(action.list);
break;
case DROP_FILES:
store.addToList(action.files);
break;
case REMOVE_FROM_LIST:
store.removeFromList(action.ids);
break;
case PLAY_SONG:
store.playSong(action.song);
break;
case PAUSE_SONG:
store.pauseSong();
break;
case PLAY_NEXT_SONG:
store.playNextOnTheList();
break;
case PLAY_PREVIOUS_SONG:
store.playPreviousOnTheList();
break;
case MUTE_SOUND:
store.mute();
break;
case UNMUTE_SOUND:
store.unmute(action.volume);
break;
case SET_VOLUME:
store.setVolume(action.volume);
break;
case RESTART_SONG:
store.restartSong();
break;
case SEEK_SONG:
store.seek(action.value);
break;
default:
break;
}
});
...
This is it, the store is pretty much done, we'll just change the needed methods from this point onwards
Let's create the DropArea
Component that will populate the list
To create the component we'll need some actions and helpers.
Let's start with the actions
app/actions/dropArea.js
import Dispatcher from '../dispatcher';
import { DRAGGING_FILES, NOT_DRAGGING_FILES, DROP_FILES } from '../events';
export default {
draggingFiles: () => {
Dispatcher.dispatch({
actionType: DRAGGING_FILES
});
},
stopDraggingFiles: () => {
Dispatcher.dispatch({
actionType: NOT_DRAGGING_FILES
});
},
addToList: (files) => {
Dispatcher.dispatch({
actionType: DROP_FILES,
files
});
}
};
There seems to be a problem with the dragenter
, dragleave
events, this util will help us with that.
Create a file called app/utils/BetterDrag.js
import EventEmitter from 'events';
class BetterDrag extends EventEmitter {
constructor(el) {
super();
this.el = el;
this.dragCollection = [];
this.onDragEnter = this.onDragEnter.bind(this);
this.preventDefault = this.preventDefault.bind(this);
this.onDragLeave = this.onDragLeave.bind(this);
this.onDrop = this.onDrop.bind(this);
this.addListeners();
}
addListeners() {
this.el.addEventListener('dragover', this.preventDefault, false);
this.el.addEventListener('dragenter', this.onDragEnter, false);
this.el.addEventListener('dragleave', this.onDragLeave, false);
this.el.addEventListener('drop', this.onDrop, false);
}
removeListeners() {
this.el.removeEventListener('dragover', this.preventDefault);
this.el.removeEventListener('dragenter', this.onDragEnter);
this.el.removeEventListener('dragleave', this.onDragLeave);
this.el.removeEventListener('drop', this.onDrop);
}
destroy() {
this.removeListeners();
}
preventDefault(e) {
e.preventDefault();
e.stopPropagation();
}
onDragEnter(e) {
this.preventDefault(e);
if (this.dragCollection.length === 0) {
this.emit('dragenter', e);
}
this.dragCollection.push(e.target);
}
onDragLeave(e) {
this.preventDefault(e);
setTimeout(() => {
const currentElementIndex = this.dragCollection.indexOf(e.target);
if (currentElementIndex > -1) {
this.dragCollection.splice(currentElementIndex, 1);
}
if (this.dragCollection.length === 0) {
this.emit('dragleave', e);
}
}, 1);
}
onDrop(e) {
this.preventDefault(e);
this.dragCollection = [];
this.emit('dragleave', e);
this.emit('drop', e);
}
}
export default BetterDrag;
Self explanatory, it will help us getting files info.
app/utils/readFiles.js
import fs from 'fs';
import File from './File';
import WaterFallOver from './WaterFallOver';
class ReadFiles {
separateDirectoriesFromFiles(inputFiles) {
const directories = [];
const files = [];
let i = 0;
let file;
let stat;
for (i = 0; i < inputFiles.length; i++) {
file = inputFiles[i];
stat = fs.statSync(file.path);
if (stat && stat.isDirectory()) {
directories.push(file.path);
} else {
files.push(new File(file.path));
}
}
return { directories, files };
}
getAllAvailableFiles(file) {
return new Promise(resolve => {
fs.stat(file, (err, stat) => {
if (stat && stat.isDirectory()) {
this.getAllFiles(file).then((res) => {
resolve(res);
});
} else {
file = new File(file);
resolve([file]);
}
});
});
}
getAllFiles(dir) {
return new Promise(resolve => {
let results = [];
let waterFall, file;
const onProcessDirectory = (obj) => {
this.getAllFiles(obj.item).then(res => {
results = results.concat(res);
obj.next();
});
};
const onProcessFile = (obj) => {
file = `${dir}/${obj.item}`;
this.getAllAvailableFiles(file).then((res) => {
results = results.concat(res);
obj.next();
});
};
const onFinishedProcessing = () => {
waterFall.removeListener('process', onProcessDirectory);
waterFall.removeListener('process', onProcessFile);
waterFall = null;
resolve(results);
};
if (Array.isArray(dir)) {
waterFall = new WaterFallOver(dir);
waterFall.on('process', onProcessDirectory);
waterFall.once('finish', onFinishedProcessing);
waterFall.execute();
} else {
fs.readdir(dir, (err, files) => {
waterFall = new WaterFallOver(files);
waterFall.on('process', onProcessFile);
waterFall.once('finish', onFinishedProcessing);
waterFall.execute();
});
}
});
}
filterFilesByType(files, type) {
return files.filter((file) => (
file.type.indexOf(type) > -1
));
}
}
export default new ReadFiles();
A helper that will contain information about the loaded files, such as the mime type, album cover.
We'll need 3 new npm modules to get this information.
Please refer to the links above if you need extra information about them
Let's install them executing command npm i mime jsmediatags album-art -S
Here's the file app/utils/File.js
import p from 'path';
import mime from 'mime';
import jsmediatags from 'jsmediatags';
import albumArt from 'album-art';
import request from 'request';
class File {
constructor(path) {
this.path = path;
this.name = p.basename(path);
this.type = mime.lookup(path);
}
set id(value) {
this._id = value;
}
get id() {
return this._id;
}
set tag(value) {
if (typeof value === 'string') {
this.title = value;
this.displayName = value;
} else {
this.artist = value.artist;
this.title = value.title;
this.picture = value.picture;
this.album = value.album;
this.displayName = `${value.artist} - ${value.title}`;
}
}
get cover() {
if (this._cover) {
return this._cover;
}
if (!this._cover && this.picture) {
const pic = this.picture;
this._cover = `data:${pic.format};base64,${Buffer.from(pic.data).toString('base64')}`;
return this._cover;
}
return false;
}
set cover(value) {
this._cover = value;
}
readTags() {
return new Promise(resolve => {
new jsmediatags.Reader(this.path)
.setTagsToRead(['title', 'artist', 'album', 'picture'])
.read({
onSuccess: (tag) => {
this.tag = tag.tags;
resolve();
},
onError: () => {
this.tag = this.name;
resolve();
}
});
});
}
searchAlbumArt(...args) {
return new Promise((resolve, reject) => {
albumArt(...args, (err, url) => {
if (!err && url) {
resolve(url);
} else {
reject(err);
}
});
});
}
findCover() {
return new Promise((resolve, reject) => {
this.searchAlbumArt(this.artist, this.album, 'large').then(url => {
this.cover = url;
this.convertCoverToBase64();
resolve(url);
}).catch(() => {
this.searchAlbumArt(this.artist, null, 'large').then(url => {
this.cover = url;
this.convertCoverToBase64();
resolve(url);
}).catch(() => {
reject();
});
});
});
}
convertCoverToBase64() {
request.get({
uri: this.cover,
encoding: null
}, (err, response, body) => {
if (!err && response.statusCode >= 200 && response.statusCode < 400) {
const type = response.headers['content-type'];
const base64Body = new Buffer(body).toString('base64');
this.cover = `data:${type};base64,${base64Body}`;
}
});
}
}
export default File;
This class will helps us to iterate sequentially through the file list.
Please refer to Mostafa Samir blog post for a more comprehensive explanation.
import EventEmitter from 'events';
class WaterFallOver extends EventEmitter {
constructor(list) {
super();
this.nextItemIndex = 0;
this.list = list;
this.totalElements = this.list.length;
this.next = this.next.bind(this);
}
execute() {
this.emit('process', { item: this.list[this.nextItemIndex], next: this.next });
}
next() {
this.nextItemIndex++;
if (this.nextItemIndex === this.totalElements) {
this.emit('finish');
} else {
this.execute();
}
}
}
export default WaterFallOver;
Create the file app/components/DropArea.js
import React, { Component } from 'react';
import BetterDrag from '../utils/BetterDrag';
import DropAreaActions from '../actions/dropArea';
import styles from './DropArea.css';
import readFiles from '../utils/readFiles';
export default class Home extends Component {
constructor() {
super();
this.onDragOver = this.onDragOver.bind(this);
this.onDragOut = this.onDragOut.bind(this);
this.onDrop = this.onDrop.bind(this);
this.state = {
hover: false
};
}
componentDidMount() {
this.betterDrag = new BetterDrag(document);
this.betterDrag.on('dragenter', this.onDragOver);
this.betterDrag.on('dragleave', this.onDragOut);
this.betterDrag.on('drop', this.onDrop);
}
componentWillUnmount() {
this.betterDrag.removeListener('dragenter', this.onDragOver);
this.betterDrag.removeListener('dragleave', this.onDragOut);
this.betterDrag.removeListener('drop', this.onDrop);
this.betterDrag.destroy();
}
onDragOver(e) {
const files = e.dataTransfer.files;
if (files.length) {
DropAreaActions.draggingFiles();
this.setState({
hover: true
});
}
}
onDragOut() {
DropAreaActions.stopDraggingFiles();
this.setState({
hover: false
});
}
onDrop(e) {
const obj = readFiles.separateDirectoriesFromFiles(e.dataTransfer.files);
let files = obj.files;
if (obj.directories.length) {
readFiles.getAllFiles(obj.directories)
.then(results => {
files = files.concat(results);
this.addFilesToList(files);
});
} else {
this.addFilesToList(files);
}
}
addFilesToList(files) {
files = readFiles.filterFilesByType(files, 'audio');
if (files.length) {
DropAreaActions.addToList(files);
}
}
render() {
return (
<div>
{ this.props.children }
<div className={ `messageArea${this.state.hover ? ' messageArea--hover' : ''}` }>
<div className={ styles.dashedContainer }>
Release to add to the list
</div>
</div>
</div>
);
}
}
And the component styles at app/component/DropArea.css
.dashedContainer {
position: absolute;
left: 20px;
right: 20px;
padding: 20px;
top: 50%;
transform: translate3d(0, -50%, 0);
text-align: center;
margin: auto;
border: 5px dashed #efefef;
}
If we now try to add the music folder to the window we should see a log in the console saying "addToList: TODO"
As you can see from the app everything seems messed up, let's fix it by adding more styles.
Let's add the following styles to app/app.global.css
.messageArea {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
opacity: 0;
pointer-events: none;
-webkit-transition: all 0.3s;
-moz-transition: all 0.3s;
transition: all 0.3s;
z-index: 10;
}
.messageArea--hover {
opacity: 1;
pointer-events: auto;
}
.menuList {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 1;
transform: translate3d(0, -100%, 0);
-webkit-transition: all 0.3s;
-moz-transition: all 0.3s;
transition: all 0.3s;
-webkit-app-region: no-drag;
}
.menuList.is-opened {
transform: translate3d(0, 0, 0);
}
.ListItem__is-even {
background: rgba(255, 255, 255, 0.1);
}
.ListItem__is-odd {
background: rgba(255, 255, 255, 0.15);
}
Much better. Let's now add those files to list
Let's open the store
app/store/index.js
...
// Import the WaterFallOver as we'll need it
import WaterFallOver from '../utils/WaterFallOver';
...
addToList(files) {
let nextId = this.songsList.length;
const waterFall = new WaterFallOver(files);
const onProcessFile = (obj) => {
obj.item.id = nextId++;
obj.item.readTags().then(() => {
this.songsList.push(obj.item);
this.emit(LIST_UPDATE);
obj.next();
});
};
const onFinish = () => {
waterFall.removeListener('process', onProcessFile);
};
waterFall.on('process', onProcessFile);
waterFall.once('finish', onFinish);
waterFall.execute();
}
And there we go, now dropping the files on the player will populate the play list
Clicking on the play button doesn't actually do much. Let's change that.
Let's create another utility that controls our mp3 file playback.
We won't use any library for this one, we'll trust the web audio api.
Let's create app/utils/AudioController.js
import EventEmitter from 'events';
import fs from 'fs';
const MAX_VOLUME = 100;
class AudioController extends EventEmitter {
constructor(volume) {
super();
const fraction = parseInt(volume, 10) / MAX_VOLUME;
const AudioContext = global.AudioContext || global.webkitAudioContext;
this.context = new AudioContext();
this.volume = fraction * fraction;
this.onSongFinished = this.onSongFinished.bind(this);
this.offsetTime = 0;
this.isPlaying = false;
this.songStartingTime = undefined;
}
play(file) {
return new Promise((resolve, reject) => {
fs.readFile(file.path, (err, data) => {
if (err) {
reject(err);
return;
}
this.context.decodeAudioData(this.toArrayBuffer(data), (buffer) => {
this.playFromBuffer(buffer);
resolve();
});
});
});
}
playFromBuffer(buffer) {
this.stop(false);
this.buffer = buffer;
this.initSource();
this.offsetTime = 0;
this.songDuration = this.buffer.duration;
this.songStartingTime = this.context.currentTime;
this.playbackTime = 0;
this.startPlaying();
}
startPlaying() {
this.isPlaying = true;
this.source.start(0, this.playbackTime);
}
initSource() {
this.source = this.context.createBufferSource();
this.gainNode = this.context.createGain();
this.analyser = this.context.createAnalyser();
this.source.buffer = this.buffer;
this.source.connect(this.gainNode);
this.source.connect(this.analyser);
this.gainNode.connect(this.context.destination);
this.gainNode.gain.value = this.volume;
this.source.onended = this.onSongFinished;
}
seek(playbackTime) {
if (this.isPlaying) {
this.stop(false);
this.initSource();
this.songStartingTime = this.context.currentTime - playbackTime;
this.playbackTime = playbackTime;
this.startPlaying();
} else {
this.songStartingTime = this.context.currentTime - playbackTime;
this.playbackTime = playbackTime;
}
}
restart() {
this.seek(0);
}
getCurrentPlayingTime() {
if (typeof this.songStartingTime !== 'undefined') {
return this.context.currentTime - this.songStartingTime;
}
}
getSongDuration() {
return this.songDuration;
}
onSongFinished() {
this.isPlaying = false;
this.songDuration = undefined;
this.songStartingTime = undefined;
this.emit('songFinished');
}
stop(report = true) {
if (this.source) {
if (!report) {
this.source.onended = undefined;
}
this.source.stop(0);
this.gainNode = null;
}
}
pause() {
this.isPlaying = false;
this.pausePlaybackTime = this.playbackTime;
this.context.suspend();
}
resume() {
this.isPlaying = true;
this.context.resume();
if (this.pausePlaybackTime !== this.playbackTime) {
this.seek(this.playbackTime);
}
}
mute() {
if (this.gainNode) {
this.savedGainValue = this.gainNode.gain.value;
this.gainNode.gain.value = 0;
}
}
unmute(volume) {
if (this.gainNode) {
this.gainNode.gain.value = volume || this.savedGainValue;
}
}
setVolume(volume) {
const fraction = parseInt(volume, 10) / MAX_VOLUME;
this.volume = fraction * fraction; // Linear (x) doesn't sound as good
if (this.gainNode) {
this.gainNode.gain.value = this.volume;
}
}
toArrayBuffer(buffer) {
const ab = new ArrayBuffer(buffer.length);
const view = new Uint8Array(ab);
for (let i = 0; i < buffer.length; ++i) {
view[i] = buffer[i];
}
return ab;
}
getFrequency(frequencyData) {
if (!this.analyser) {
return;
}
if (!frequencyData) {
frequencyData = new Uint8Array(this.analyser.frequencyBinCount);
}
this.analyser.getByteFrequencyData(frequencyData);
return frequencyData;
}
frequencyToIndex(frequency, sampleRate, frequencyBinCount) {
const nyquist = sampleRate / 2;
const index = Math.round(frequency / nyquist * frequencyBinCount);
return this.clamp(index, 0, frequencyBinCount);
}
analyserAverage(frequencies, minHz, maxHz) {
const div = 255;
const sampleRate = this.analyser.context.sampleRate;
const binCount = this.analyser.frequencyBinCount;
let start = this.frequencyToIndex(minHz, sampleRate, binCount);
const end = this.frequencyToIndex(maxHz, sampleRate, binCount);
const count = end - start;
let sum = 0;
for (; start < end; start++) {
sum += frequencies[start] / div;
}
return count === 0 ? 0 : (sum / count);
}
clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
}
export default AudioController;
That's all we need to grab all the information we'll need for this player.
Let's open the store file and play that clicked song
app/store/index.js
import AudioController from '../utils/AudioController';
...
constructor() {
super();
this.songsList = [];
this.playingSong = null;
this.volume = 100;
this.isMute = false;
this.onSongFinished = this.onSongFinished.bind(this);
this.audioController = new AudioController(this.volume);
this.audioController.on('songFinished', this.onSongFinished);
}
...
playSong(file) {
if (this.playingSong && this.playingSong.paused && (!file || this.playingSong.id === file.id)) {
this.resumeSong();
} else if (!file) {
this.playNextOnTheList();
} else {
this.playingSong = file;
this.audioController.play(file);
this.emit(STARTED_PLAYING, file);
}
}
resumeSong() {
this.playingSong.paused = false;
this.audioController.resume();
this.emit(STARTED_PLAYING, this.playingSong);
}
pauseSong() {
this.playingSong.paused = true;
this.audioController.pause();
this.emit(STARTED_PLAYING, this.playingSong);
}
There we go, now our app plays the song when we press play on the play list
We'll need an extra npm module called react-input-range
npm i react-input-range -S
from the command line to install it
app/components/MusicControls.js
import React, { Component } from 'react';
import store from '../store';
import VolumeControl from './VolumeControl';
import Seeker from './Seeker';
import styles from './MusicControls.css';
import MusicActions from '../actions/music';
import { STARTED_PLAYING, STOPPED_PLAYING } from '../events';
// Decide whether to change track or restart the current one
const TIME_BEFORE_CHANGING_TRACK = 1000;
class MusicControls extends Component {
constructor() {
super();
this.state = {
songPlaying: false
};
this.onSongPlaying = this.onSongPlaying.bind(this);
this.onSongStopped = this.onSongStopped.bind(this);
this.onPauseClicked = this.onPauseClicked.bind(this);
this.onPlayClicked = this.onPlayClicked.bind(this);
this.onPreviousClicked = this.onPreviousClicked.bind(this);
this.onNextClicked = this.onNextClicked.bind(this);
this.lastTimeClicked = 0;
}
componentDidMount() {
store.on(STARTED_PLAYING, this.onSongPlaying);
store.on(STOPPED_PLAYING, this.onSongStopped);
}
componentWillUnmount() {
store.removeListener(STARTED_PLAYING, this.onSongPlaying);
store.removeListener(STOPPED_PLAYING, this.onSongStopped);
}
onSongPlaying(song) {
this.setState({
songPlaying: !song.paused
});
}
onSongStopped() {
this.setState({
songPlaying: false
});
}
onPlayClicked() {
MusicActions.playSong();
}
onPauseClicked() {
MusicActions.pauseSong();
}
onPreviousClicked() {
if (Date.now() - this.lastTimeClicked < TIME_BEFORE_CHANGING_TRACK) {
MusicActions.playPreviousSong();
} else {
MusicActions.restartSong();
}
this.lastTimeClicked = Date.now();
}
onNextClicked() {
MusicActions.playNextSong();
}
render() {
return (
<div className={ styles.container }>
<div className={ styles.basicControls }>
<div onClick={ this.onPreviousClicked } className={ styles.previousButton }></div>
{ this.state.songPlaying ?
<div onClick={ this.onPauseClicked } className={ styles.pauseButton }></div> :
<div onClick={ this.onPlayClicked } className={ styles.playButton }></div>
}
<div onClick={ this.onNextClicked } className={ styles.nextButton }></div>
</div>
<Seeker />
<VolumeControl className={ styles.volumeControl } />
</div>
);
}
}
export default MusicControls;
And the styles app/components/MusicControls.css
.container {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
height: 30px;
}
.volumeControl {
position: absolute;
right: 54px;
top: 0;
}
.basicControls {
position: relative;
float: left;
left: 10px;
}
.previousButton,
.pauseButton,
.playButton,
.nextButton {
position: relative;
float: left;
margin: 0 4px;
background-size: 100%;
cursor: pointer;
width: 15px;
height: 15px;
top: 6px;
}
.playButton,
.pauseButton {
width: 30px;
height: 30px;
top: 0;
}
.previousButton {
background-image: url("../assets/img/previous.svg");
}
.nextButton {
background-image: url("../assets/img/next.svg");
}
.playButton {
background-image: url("../assets/img/play.svg");
}
.pauseButton {
background-image: url("../assets/img/pause.svg");
}
.basicControls div:first-child {
margin-left: 0;
}
.basicControls div:last-child {
margin-right: 0;
}
app/components/Seeker.js
import React, { Component } from 'react';
import store from '../store';
import InputRange from 'react-input-range';
import MusicActions from '../actions/music';
import styles from './Seeker.css';
import { STARTED_PLAYING, STOPPED_PLAYING, ANIMATION_FRAME } from '../events';
class Seeker extends Component {
constructor() {
super();
this.state = {
playingTime: undefined,
songTime: undefined
};
this.stopGrabbingPlaytime = true;
this.setSongTime = this.setSongTime.bind(this);
this.onScrubChange = this.onScrubChange.bind(this);
this.onScrubMouseDown = this.onScrubMouseDown.bind(this);
this.onScrubMouseUp = this.onScrubMouseUp.bind(this);
this.onSongStartPlaying = this.onSongStartPlaying.bind(this);
this.onSongStoppedPlaying = this.onSongStoppedPlaying.bind(this);
}
componentDidMount() {
store.on(ANIMATION_FRAME, this.setSongTime);
store.on(STARTED_PLAYING, this.onSongStartPlaying);
store.on(STOPPED_PLAYING, this.onSongStoppedPlaying);
global.store = store;
}
componentWillUnmount() {
store.removeListener(ANIMATION_FRAME, this.setSongTime);
store.removeListener(STARTED_PLAYING, this.onSongStartPlaying);
store.removeListener(STOPPED_PLAYING, this.onSongStoppedPlaying);
}
onSongStartPlaying() {
this.stopGrabbingPlaytime = false;
this.setState({
playingTime: store.getPlaytime(),
songTime: store.getSongDuration()
});
}
onSongStoppedPlaying() {
this.stopGrabbingPlaytime = true;
this.setState({
playingTime: undefined,
songTime: undefined
});
}
onScrubChange(component, value) {
this.seekSongTo = value / 1000;
this.setState({
playingTime: value / 1000
});
}
setSongTime() {
if (this.stopGrabbingPlaytime) {
return;
}
this.setState({
playingTime: store.getPlaytime(),
songTime: store.getSongDuration()
});
}
pad(value, size = 2) {
let s = String(value);
while (s.length < size) {
s = `0${s}`;
}
return s;
}
transformTime(time) {
if (Number.isNaN(time)) {
return '- -:- -';
}
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time - minutes * 60);
return `${minutes}:${this.pad(seconds)}`;
}
validateValue(value) {
if (Number.isNaN(value)) {
return 0;
}
return Math.min(value, this.state.songTime * 1000);
}
onScrubMouseDown(e) {
const className = e.target.className;
if (className.indexOf('InputRange-') === -1) {
return;
}
this.stopGrabbingPlaytime = true;
document.addEventListener('mouseup', this.onScrubMouseUp);
}
onScrubMouseUp() {
document.removeEventListener('mouseup', this.onScrubMouseUp);
this.stopGrabbingPlaytime = false;
MusicActions.seek(this.seekSongTo);
}
render() {
const playTime = Math.floor(this.state.playingTime);
const scrubTime = this.validateValue(playTime * 1000);
const totalTime = Math.floor(this.state.songTime);
return (
<div className={ styles.container }>
<div className={ styles.playingTime }>{ this.transformTime(playTime) }</div>
<div className="timeRange" onMouseDown={ this.onScrubMouseDown }>
<InputRange minValue={ 0 } maxValue={ Math.max(this.validateValue(totalTime * 1000), 1) } value={ scrubTime } onChange={ this.onScrubChange }/>
</div>
<div className={ styles.songTime }>{ this.transformTime(totalTime) }</div>
</div>
);
}
}
export default Seeker;
and the styles app/component/Seeker.css
.container {
position: absolute;
top: 0;
left: 100px;
right: 90px;
}
.playingTime {
position: absolute;
font-size: 10px;
left: 0;
top: 0;
line-height: 30px;
text-align: center;
width: 25px;
}
.songTime {
position: absolute;
font-size: 10px;
right: 0;
top: 0;
line-height: 30px;
text-align: center;
width: 25px;
}
app/components/VolumeControl.js
import React, { Component } from 'react';
import store from '../store';
import styles from './VolumeControl.css';
import MusicActions from '../actions/music';
import VolumeIconSvg from './VolumeIconSvg';
import InputRange from 'react-input-range';
import { MUTE_SOUND, UNMUTE_SOUND } from '../events';
const MIN_VOLUME = 0;
const MAX_VOLUME = 100;
class VolumeControl extends Component {
constructor() {
super();
this.state = {
mute: store.isMuted(),
volume: store.getVolume()
};
this.savedVolume = this.state.volume;
this.toggleVolume = this.toggleVolume.bind(this);
this.onSoundMuted = this.onSoundMuted.bind(this);
this.onSoundUnmuted = this.onSoundUnmuted.bind(this);
this.onVolumeRangeChange = this.onVolumeRangeChange.bind(this);
}
componentDidMount() {
store.on(MUTE_SOUND, this.onSoundMuted);
store.on(UNMUTE_SOUND, this.onSoundUnmuted);
}
componentWillUnmount() {
store.removeListener(MUTE_SOUND, this.onSoundMuted);
store.removeListener(UNMUTE_SOUND, this.onSoundUnmuted);
}
onSoundMuted() {
this.savedVolume = this.state.volume;
this.setState({
mute: true,
volume: 0
});
}
onSoundUnmuted() {
this.setState({
mute: false,
volume: this.savedVolume
});
}
onVolumeRangeChange(component, value) {
this.setState({
volume: value
});
MusicActions.setVolume(value);
}
toggleVolume() {
if (this.state.mute) {
MusicActions.unmute(this.savedVolume);
} else {
MusicActions.mute();
}
}
render() {
return (
<div className={ this.props.className }>
<div className="volumeRange">
<InputRange minValue={ MIN_VOLUME } maxValue={ MAX_VOLUME } value={ this.state.volume } onChange={ this.onVolumeRangeChange }/>
</div>
<div className={ styles.volumeButton } onClick={ this.toggleVolume }>
<VolumeIconSvg volume={ this.state.volume / MAX_VOLUME } className={ styles.volumeIcon } fill="#FFFFFF"/>
</div>
</div>
);
}
}
export default VolumeControl;
and the styles app/components/VolumeControl.css
.volumeIcon {
width: 30px;
height: 30px;
cursor: pointer;
}
.volumeButton {
cursor: pointer;
width: 30px;
height: 30px;
}
This component will just be an svg that will change the icon depending on the volume
app/components/VolumeIconSvg.js
import React, { Component } from 'react';
class VolumeIconSvg extends Component {
renderSoundOn() {
return (
<g>
<path d={`M34.437,7.413c-0.979-0.561-2.143-0.553-3.115,0.019c-0.063,0.037-0.121,0.081-0.174,0.131L17.906,19.891
C17.756,19.963,17.593,20,17.427,20H9.104C7.392,20,6,21.393,6,23.104v12.793C6,37.607,7.392,39,9.104,39h8.324
c0.166,0,0.329,0.037,0.479,0.109l13.242,12.328c0.053,0.05,0.112,0.094,0.174,0.131c0.492,0.289,1.033,0.434,1.574,0.434
c0.529,0,1.058-0.138,1.541-0.415C35.416,51.027,36,50.021,36,48.894V10.106C36,8.979,35.416,7.973,34.437,7.413z M34,48.894
c0,0.577-0.389,0.862-0.556,0.958c-0.158,0.09-0.562,0.262-1.025,0.037l-13.244-12.33c-0.054-0.051-0.113-0.095-0.176-0.131
C18.522,37.147,17.979,37,17.427,37H9.104C8.495,37,8,36.505,8,35.896V23.104C8,22.495,8.495,22,9.104,22h8.324
c0.551,0,1.095-0.147,1.572-0.428c0.063-0.036,0.122-0.08,0.176-0.131l13.244-12.33c0.465-0.226,0.868-0.053,1.025,0.037
C33.611,9.244,34,9.529,34,10.106V48.894z`} />
{ this.props.volume > 0 ? <path d={`M39.707,20.293c-0.391-0.391-1.023-0.391-1.414,0s-0.391,1.023,0,1.414c4.297,4.297,4.297,11.289,0,15.586
c-0.391,0.391-0.391,1.023,0,1.414C38.488,38.902,38.744,39,39,39s0.512-0.098,0.707-0.293
C44.784,33.63,44.784,25.37,39.707,20.293z`}/> : null }
{ this.props.volume >= 0.35 ? <path d={`M43.248,17.293c-0.391-0.391-1.023-0.391-1.414,0s-0.391,1.023,0,1.414c6.238,6.238,6.238,16.39,0,22.628
c-0.391,0.391-0.391,1.023,0,1.414c0.195,0.195,0.451,0.293,0.707,0.293s0.512-0.098,0.707-0.293
C50.266,35.73,50.266,24.312,43.248,17.293z`}/> : null }
{ this.props.volume >= 0.70 ? <path d={`M46.183,12.293c-0.391-0.391-1.023-0.391-1.414,0s-0.391,1.023,0,1.414c4.356,4.355,6.755,10.142,6.755,16.293
s-2.399,11.938-6.755,16.293c-0.391,0.391-0.391,1.023,0,1.414C44.964,47.902,45.22,48,45.476,48s0.512-0.098,0.707-0.293
c4.734-4.733,7.341-11.021,7.341-17.707S50.917,17.026,46.183,12.293z`}/> : null }
<path d={`M30,0C13.458,0,0,13.458,0,30s13.458,30,30,30s30-13.458,30-30S46.542,0,30,0z M30,58C14.561,58,2,45.439,2,30
S14.561,2,30,2s28,12.561,28,28S45.439,58,30,58z`}/>
</g>
);
}
renderMuted() {
return (
<path d={`M51.213,8.78C39.517-2.917,20.484-2.916,8.787,8.78C3.121,14.446,0,21.98,0,29.993S3.121,45.54,8.787,51.206
c5.848,5.849,13.531,8.772,21.213,8.772s15.365-2.924,21.213-8.772C62.91,39.509,62.91,20.477,51.213,8.78z M10.201,10.194
C15.66,4.736,22.83,2.007,30,2.007c6.858,0,13.713,2.504,19.074,7.498L42,16.579v-6.479c0-1.127-0.584-2.134-1.563-2.693
c-0.978-0.561-2.143-0.553-3.115,0.019c-0.063,0.037-0.121,0.081-0.174,0.131L23.906,19.884c-0.149,0.072-0.313,0.109-0.479,0.109
h-8.324c-1.711,0-3.104,1.393-3.104,3.104v12.793c0,1.711,1.392,3.104,3.104,3.104h4.482L9.511,49.068
C4.664,43.869,2,37.137,2,29.993C2,22.514,4.913,15.483,10.201,10.194z M21.586,36.993h-6.482c-0.608,0-1.104-0.495-1.104-1.104
V23.096c0-0.608,0.495-1.104,1.104-1.104h8.324c0.551,0,1.095-0.147,1.572-0.428c0.063-0.036,0.122-0.08,0.176-0.131l13.244-12.33
c0.465-0.226,0.868-0.053,1.025,0.037C39.611,9.237,40,9.522,40,10.099v8.479L21.586,36.993z M40,21.407v27.479
c0,0.577-0.389,0.862-0.556,0.958c-0.158,0.09-0.562,0.262-1.025,0.037l-13.244-12.33c-0.054-0.051-0.113-0.095-0.176-0.131
c-0.224-0.132-0.466-0.229-0.713-0.3L40,21.407z M49.799,49.792c-10.68,10.679-27.908,10.904-38.873,0.689l11.488-11.488h1.013
c0.166,0,0.329,0.037,0.479,0.109L37.148,51.43c0.053,0.05,0.112,0.094,0.174,0.131c0.492,0.289,1.033,0.434,1.574,0.434
c0.529,0,1.058-0.138,1.541-0.415C41.416,51.02,42,50.013,42,48.887V19.407l8.488-8.488C60.704,21.884,60.479,39.112,49.799,49.792z
`}/>
);
}
render() {
return (
<svg fill={ this.props.fill } className={ this.props.className } viewBox="0 0 60 60" preserveAspectRatio="xMidYMid meet">
{ this.props.volume === 0 ? this.renderMuted() : this.renderSoundOn() }
</svg>
);
}
}
export default VolumeIconSvg;
What this will do is to trigger an event every single frame, so we can update the seeker and later on the spectrum
Open app/store/index.js
...
constructor() {
super();
this.songsList = [];
this.playingSong = null;
this.volume = 100;
this.isMute = false;
this.onSongFinished = this.onSongFinished.bind(this);
this.reportAnimation = this.reportAnimation.bind(this);
this.audioController = new AudioController(this.volume);
this.audioController.on('songFinished', this.onSongFinished);
this.reportAnimation();
}
reportAnimation() {
this.emit(ANIMATION_FRAME);
global.requestAnimationFrame(this.reportAnimation);
}
...
Let's now add the controllers to our app/components/Home.js
...
import MusicControls from './MusicControls';
...
render() {
let className = styles.homeContainer;
return (
<div className={ className }>
<div className={ styles.playerTitle }>
<div className={ styles.closeButton } onClick={this.onCloseClick}></div>
<div className={ styles.menuButton } onClick={this.onMenuClick}></div>
</div>
<div className={styles.musicControls}>
<MusicControls />
</div>
</div>
);
}
We need to add more stylings to app/components/Home.css
...
.musicControls {
position: absolute;
top: 30%;
bottom: 0;
left: 0;
right: 0;
text-align: center;
}
.musicControls h2 {
font-size: 5rem;
}
.musicControls a {
font-size: 1.4rem;
}
The result is probably not what we expected, but we'll fix it later
Let's finish the store now so we focus ourselves in the styling of the app
Open app/store/index.js
...
import {
CLOSE_WINDOW,
REORDER_LIST,
OPEN_LIST,
CLOSE_LIST,
DRAGGING_FILES,
NOT_DRAGGING_FILES,
DROP_FILES,
REMOVE_FROM_LIST,
LIST_UPDATE,
STARTED_PLAYING,
STOPPED_PLAYING,
PLAY_SONG,
PAUSE_SONG,
PLAY_NEXT_SONG,
PLAY_PREVIOUS_SONG,
RESTART_SONG,
SEEK_SONG,
MUTE_SOUND,
UNMUTE_SOUND,
SET_VOLUME,
ANIMATION_FRAME
} from '../events';
const NUMBER_OF_SPECTRUM_BARS = 16;
...
stopSong(file) {
if (!file) {
return;
}
this.emit(STOPPED_PLAYING, this.playingSong);
this.playingSong = null;
this.audioController.stop(false);
}
restartSong() {
this.audioController.restart();
}
...
mute() {
this.isMute = true;
this.audioController.mute();
this.emit(MUTE_SOUND);
}
unmute(volume) {
this.isMute = false;
this.audioController.unmute(volume);
this.emit(UNMUTE_SOUND);
}
setVolume(volume) {
this.audioController.setVolume(volume);
this.volume = volume;
this.emit(SET_VOLUME, volume);
}
getPlaytime() {
return this.audioController.getCurrentPlayingTime();
}
getSongDuration() {
return this.audioController.getSongDuration();
}
seek(value) {
this.audioController.seek(value);
}
getFrequency() {
// we want to reuse the array to avoid creating multiple ones
const average = new Array(NUMBER_OF_SPECTRUM_BARS).fill(0);
let i = 0;
this.frequencies = this.frequencies || null;
this.frequencies = this.audioController.getFrequency(this.frequencies);
if (!this.frequencies) {
return average;
}
const averageStep = this.frequencies.length / NUMBER_OF_SPECTRUM_BARS;
for (i = 0; i < NUMBER_OF_SPECTRUM_BARS; i++) {
average[i] = this.audioController.analyserAverage(this.frequencies, i * averageStep, (i + 1) * averageStep);
}
return average;
}
There we go, we should now be all done with the store
Open the app/app.html
file and add the input range css
...
<link rel="stylesheet" href="../node_modules/font-awesome/css/font-awesome.min.css" />
<link rel="stylesheet" href="../node_modules/react-input-range/dist/react-input-range.min.css" />
...
Add it as an external component to webpack.config.electron.js
...
externals: [
'font-awesome',
'react-input-range',
'source-map-support'
]
...
Open app/app.global.css
and add the following
...
.volumeRange {
position: absolute;
left: 40px;
width: 30px;
height: 30px;
top: 7px;
}
.timeRange {
position: absolute;
left: 35px;
right: 35px;
height: 30px;
top: 7px;
}
.volumeRange .InputRange,
.timeRange .InputRange {
cursor: default;
}
.timeRange .InputRange-label--min,
.timeRange .InputRange-label--max,
.timeRange .InputRange-label--value,
.volumeRange .InputRange-label--min,
.volumeRange .InputRange-label--max,
.volumeRange .InputRange-label--value {
display: none;
}
.timeRange .InputRange-slider,
.volumeRange .InputRange-slider {
top: 2px;
width: 10px;
height: 10px;
}
.timeRange .InputRange-sliderContainer,
.timeRange .InputRange-track,
.volumeRange .InputRange-sliderContainer,
.volumeRange .InputRange-track {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
Here's the result
Looking good
Create a new component app/components/SongCover.js
import React, { Component } from 'react';
import configureStore from '../store/configureStore';
import styles from './SongCover.css';
import { STARTED_PLAYING } from '../events';
const UNKNOWN_URL = './assets/img/unknown-cover.svg';
class SongCover extends Component {
constructor() {
super();
this.onSongPlaying = this.onSongPlaying.bind(this);
this.state = {
url: UNKNOWN_URL
};
}
componentDidMount() {
configureStore.on(STARTED_PLAYING, this.onSongPlaying);
}
componentWillUnmount() {
configureStore.removeListener(STARTED_PLAYING, this.onSongPlaying);
}
onSongPlaying(file) {
if (file.cover) {
this.setState({
url: file.cover
});
} else {
file.findCover().then((cover) => {
this.setState({
url: cover
});
}).catch(() => {
this.setState({
url: UNKNOWN_URL
});
});
}
}
render() {
const innerStyles = {
backgroundImage: `url(${this.state.url})`
};
return (
<div className={ styles.container } style={ innerStyles }></div>
);
}
}
export default SongCover;
And the styles app/components/SongCover.css
.container {
position: relative;
width: 200px;
height: 200px;
overflow: hidden;
border-radius: 50%;
background-size: 100%;
margin: auto;
}
Now let's add the component to app/components/Home.js
...
import SongCover from './SongCover';
...
render() {
let className = styles.homeContainer;
return (
<div className={ className }>
<div className={ styles.playerTitle }>
<div className={ styles.closeButton } onClick={this.onCloseClick}></div>
<div className={ styles.menuButton } onClick={this.onMenuClick}></div>
</div>
<div className={styles.musicControls}>
<SongCover />
<MusicControls />
</div>
</div>
);
}
Let's create a new file app/components/PlayerSpectrum.js
import React, { Component } from 'react';
import store from '../store';
import styles from './PlayerSpectrum.css';
import { ANIMATION_FRAME } from '../events';
class PlayerSpectrum extends Component {
constructor() {
super();
this.state = {
bars: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
};
this.getSpectrum = this.getSpectrum.bind(this);
}
componentDidMount() {
store.on(ANIMATION_FRAME, this.getSpectrum);
}
componentWillUnmount() {
store.removeListener(ANIMATION_FRAME, this.getSpectrum);
}
getSpectrum() {
this.setState({
bars: store.getFrequency()
});
}
render() {
return (
<div className={ styles.container }>
{
this.state.bars.map((item, index) => {
return (
<div key={ index } className={ styles.spectrumBar } style={{ left: `${6.25 * index}%`, height: `${Math.ceil(this.state.bars[index] * 100)}%` }}></div>
);
})
}
</div>
);
}
}
export default PlayerSpectrum;
With the styles app/components/PlayerSpectrum.css
.container {
position: absolute;
width: 100%;
height: 200px;
bottom: 0;
left: 0;
-webkit-filter: url("#goo");
filter: url("#goo");
}
.spectrumBar {
position: absolute;
width: 6.25%;
bottom: 0;
float: left;
background: #ad2a2a;
}
And again let's add it to app/components/Home.js
...
import PlayerSpectrum from './PlayerSpectrum';
...
render() {
let className = styles.homeContainer;
return (
<div className={ className }>
<div className={ styles.playerTitle }>
<div className={ styles.closeButton } onClick={this.onCloseClick}></div>
<div className={ styles.menuButton } onClick={this.onMenuClick}></div>
</div>
<div className={styles.musicControls}>
<PlayerSpectrum />
<SongCover />
<MusicControls />
</div>
</div>
);
}
For better understanding of the effect please refer to the codrops article
Open app/app.html
and add the following to the top of the body
...
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="800" style="display: none; position: absolute; top: 0; left: 0;">
<defs>
<filter id="goo">
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9" result="goo" />
</filter>
</defs>
</svg>
...
And we're done
Open app/components/Home.js
import React, { Component } from 'react';
import store from '../store';
import styles from './Home.css';
import HomeActions from '../actions/home';
import MusicControls from './MusicControls';
import SongCover from './SongCover';
import PlayerSpectrum from './PlayerSpectrum';
import { DRAGGING_FILES, NOT_DRAGGING_FILES, OPEN_LIST, CLOSE_LIST } from '../events';
...
constructor() {
super();
this.onCloseClick = this.onCloseClick.bind(this);
this.onDragStart = this.onDragStart.bind(this);
this.onDragStop = this.onDragStop.bind(this);
this.onMenuOpened = this.onMenuOpened.bind(this);
this.onMenuClosed = this.onMenuClosed.bind(this);
this.state = {
isDragging: false,
isMenuOpened: false
};
}
componentDidMount() {
store.on(DRAGGING_FILES, this.onDragStart);
store.on(NOT_DRAGGING_FILES, this.onDragStop);
store.on(OPEN_LIST, this.onMenuOpened);
store.on(CLOSE_LIST, this.onMenuClosed);
}
componentWillUnmount() {
store.removeListener(DRAGGING_FILES, this.onDragStart);
store.removeListener(NOT_DRAGGING_FILES, this.onDragStop);
store.removeListener(OPEN_LIST, this.onMenuOpened);
store.removeListener(CLOSE_LIST, this.onMenuClosed);
}
onMenuOpened() {
this.setState({
isMenuOpened: true
});
}
onMenuClosed() {
this.setState({
isMenuOpened: false
});
}
onDragStart() {
this.setState({
isDragging: true
});
}
onDragStop() {
this.setState({
isDragging: false
});
}
...
render() {
let className = styles.homeContainer;
if (this.state.isDragging) {
className += ` ${styles.filesOver}`;
}
if (this.state.isMenuOpened) {
className += ` ${styles.fileListOpened}`;
}
return (
<div className={ className }>
<div className={ styles.playerTitle }>
<div className={ styles.closeButton } onClick={this.onCloseClick}></div>
<div className={ styles.menuButton } onClick={this.onMenuClick}></div>
</div>
<div className={styles.musicControls}>
<PlayerSpectrum />
<SongCover />
<MusicControls />
</div>
</div>
);
}
We are done with this one now.
Let's build it using npm run package
When the command is complete open the generated file from the release
folder and give it a go.
Hope you liked it.
Please use the issues tab for feedback or actual issues found during the tutorial.
Till next time.