What we're building
We'll use Quasar and WaveSurfer to build a SoundCloud like cross-platform mobile audio player app. We'll load a local audio file from the device using html file input, render a waveform and add controls to play the audio.
Quasar?
Quasar is a UI framework built on Vue.js. It seems more mobile-oriented than Vuetify, and also has an excellent CLI that works great for generating Cordova apps. You get debugging on actual device with hot reload out-of-the-box. This is a crucial part of an efficient dev process, and one of the determining factors for me when choosing a framework /stack.
More points that made me want to try it out:
- Vibrant and active community (11K GitHub stars)
- Great CLI
- Beautiful UI components and grid system built in (I like to write as little CSS as possible)
- Use the Vue ecosystem you know and love
Scaffolding
I assume you already have a working Cordova setup with android studio / xcode configured. Just follow quasar's tutorial
Let's start by installing the Quasar CLI. From your workspace terminal run:
npm install -g @quasar/cli
Then create a project:
quasar create quasar-wavesurfer-audio-player
Insert the following answers:
- Project name: default
- Project product name: change to "Cool Player"
- Project Description: A soundcloud-like audio player using quasar and wavesurfer
- Author: you
- Features: we don't need any features
- Cordova id: leave as is, unless you want to publish to the store(s)
Quasar CLI will install required packages. After it's done, enter the folder, and run quasar dev
to see the basic layout:
cd quasar-wavesurfer-audio-player
quasar dev
A working version! This is a good time to init a git repo and make the first commit.
Adding the cordova mode
Now we have a Quasar app running in SPA mode. In order to turn it into a mobile app we can then publish to the app store, we need to add Quasar's cordova mode
:
quasar mode add cordova
This Quasar CLI command adds an src-cordova
folder, containing our Cordova project. Within this folder we can run any Cordova CLI command. Quasar will build our Vue code and put the assets in src-cordova/www
folder.
Now we'll check if it's running on our device. This should be done as early as possible as a sanity check to see that the setup's working. I'll use android for this, but it works the same for iOS.
Make sure your device is connected by USB, and run:
quasar dev -m android
Note: Newer chrome versions block cleartext traffic by default, so Cordova's internal WebView will throw an
ERR_CLEARTEXT_NOT_PERMITTED
error. To solve this, uncommenthttps: true,
underdevServer
section inquasar.conf.js
Note2: If you're building for iOS, it's recommended to upgrade from the deprecated UIWebView (that Cordova uses by default) to WKWebView using one of the methods described here
Now the CLI asks you which IP to serve on. Choose an IP that's accessible from your mobile device (they have to be on the same network). Quasar will create a Cordova app that's inner WebView loads from this IP, thereby giving us the ability to develop with hot reload on our device. This is a crucial feature for an efficient dev process.
We'll continue developing with quasar dev
, while periodically testing on an actual device to see things are working.
If you're building an app you want to work on android and iOS, I recommend periodically running it on both platforms. They are never the same.
Quasar Project Structure
Now let's have a look at the project structure the CLI has created for us, for a quick overview of Quasar. Quasar CLI scaffolds a folder structure with the default layout MyLayout.vue
inside the layouts
folder. The layout is a Vue component that has all the Quasar UI elements including a navigation drawer, a toolbar and a main page. You can spot the Quasar components starting with q-
e.g. q-layout
, q-toolbar
etc. These are all Vue components written by the Quasar team (similar to the v-
components of Vuetify).
In order to use a Quasar UI component, you need to explicitly include them in quasar.conf.js
, as we'll see later.
The main page is contained inside the <q-page-container>
element in MyLayout.vue
, which contains a <router-view />
- the same vue-router
you know and love. The routes are saved in the pages
folder.
The main page loaded by default is Index.vue
under the pages
folder, so we'll add our code there for this example.
Adding wavesurfer.js
WaveSurfer is an open source audio player that renders the audio wave onto an html5 canvas. Let's add it:
npm i wavesurfer.js
Now we'll import WaveSurfer into Index.vue
, add a wavesurfer
data member, and a method createWaveSurfer
that will initialize it when the component is mounted. The wavesurfer
object needs a container
, which is the ID of the html element it will be rendered in.
So we'll also add a container div to the component's template. We'll load a demo mp3 file just for testing at this stage. This is how Index.vue
looks like now:
<template>
<div id="waveform"></div>
</template>
<script>
import WaveSurfer from "wavesurfer.js";
export default {
name: 'PageIndex',
data: () => ({
wavesurfer: null,
}),
async mounted() {
if (!this.wavesurfer) this.createWaveSurfer();
},
methods: {
createWaveSurfer() {
this.wavesurfer = WaveSurfer.create({
container: "#waveform",
barWidth: 3
});
this.wavesurfer.load(
"https://ia902606.us.archive.org/35/items/shortpoetry_047_librivox/song_cjrg_teasdale_64kb.mp3"
);
}
}
}
</script>
Running using quasar dev
you should see the waveform rendered:
Loading a local audio file
We'll use html's <input type="file">
to load files from the local device. Let's add a file-loading button to our toolbar. In MyLayout.vue
replace <div>Quasar v{{ $q.version }}</div>
with a q-btn
component:
<q-btn color="white" text-color="primary">
Load File
<input
type="file"
class="q-uploader__input overflow-hidden absolute-full"
v-on:change="fileChosen"
ref="fileInput"
accept="audio/mpeg"
/>
</q-btn>
What we have here is a <q-btn>
(Quasar button), containing a regular html <input>
element, with type="file"
(making it a file chooser) that will only accept audio files (accept="audio/mpeg"
).
Notice that browsers try to enforce file inputs to have one of the internet's ugliest designs, something like this:
While there is no official way to change the appearance of a file input, we'll "borrow" the css classes from Quasar's q-uploader
component in order to make it appear more like a button, and less like a relic from the 90's. Clicking on this in a Cordova app will open the native device's file choosing interface.
In the methods section add a handler for receiving the file:
fileChosen(file) {
// Chosen file passed as argument
}
Sending the file to the main page
As we have the file object received in the MyLayout
component, but the wavesurfer object in the Index
component, we'll use an event bus to communicate between them and send the file when it's selected. An event bus can easily be created by using another Vue object. Add a services
folder with a new event-bus.js
file, containing:
import Vue from 'vue';
export const EventBus = new Vue();
We'll import it in MyLayout.vue
and emit an event whenever the file input value changes:
import { EventBus } from "../services/event-bus.js";
...
methods: {
fileChosen(file) {
EventBus.$emit("fileChosen", file);
}
}
Now we'll want to catch the event in Index.vue
's mounted
handler:
import { EventBus } from "../services/event-bus.js";
...
mounted() {
...
EventBus.$on("fileChosen", file => {
this.loadFile(file);
});
}
And finally we'll load the file using wavesurfer's loadBlob
method:
loadFile(file) {
if (file.target.files.length == 0) return;
this.wavesurfer.loadBlob(file.target.files[0]);
}
Try loading a file from your device, and you should see it rendered:
Adding controls
Let's add some play/pause/skip buttons. In Index.vue
add the following code to the template section:
<template>
<q-page class>
<div class="audio-container">
<div class="row q-ma-md">
<div class="col-12">
<div id="waveform"></div>
</div>
</div>
</div>
<div class="controls row flex flex-center fixed-bottom q-pb-md q-pt-md shadow-10">
<div class>
<q-btn
color="primary"
flat
round
icon="fast_rewind"
size="xl"
@click="wavesurfer.skipBackward(1)"
/>
<q-btn
v-if="isPlaying"
color="primary"
round
icon="pause"
size="xl"
@click="wavesurfer.playPause()"
/>
<q-btn
v-if="!isPlaying"
color="primary"
round
icon="play_arrow"
size="xl"
@click="wavesurfer.playPause()"
/>
<q-btn
color="primary"
flat
round
icon="fast_forward"
size="xl"
@click="wavesurfer.skipForward(1)"
/>
</div>
</div>
</q-page>
</template>
And in the script section:
computed: {
isPlaying() {
if (!this.wavesurfer) return false;
return this.wavesurfer.isPlaying();
}
},
We've added some controls using Quasar's <q-btn>
components inside a Quasar "row"
div, which arranges them neatly using the positioning helpers flex
, fixed-center
, and fixed-bottom
. q-pb-md/q-pt-md
gives us some predefined margins. The @click
handlers call wavesurfer's methods directly.
Notice how we're designing the UI declaratively using the template section, with Quasar's modifiers like round
, flat
and size
. This saves us 99% of the css we'd have to write manually.
We've also added an isPlaying
computed, which is a wrapper around wavesurfer's method, in order to decide whether to show a play or pause button. It should look like this:
Using wavesurfer hooks to add a loading spinner
Almost done! Now we'll add a spinner in order to give some feedback to the user when the file is loading. Wavesurfer exposes some hooks so we can handle various events. We'll use the error
, loading
and ready
events.
In quasar.conf.js
components section, add 'QCircularProgress'
to the list.
Then in Index.vue
, above the play button, add a q-circular-progress
:
<q-circular-progress v-if="isLoading" size="72px" indeterminate color="primary" />
Change the play button to show only if we're not loading:
<q-btn v-if="!isPlaying && !isLoading"
...
Add the isLoading
member to the data
section:
isLoading: false
And in the createWaveSurfer()
method, we'll hook into wavesurfer events:
this.wavesurfer.on("error", err => {
console.error(err);
this.isLoading = false;
this.$q.notify({ message: err });
});
this.wavesurfer.on("loading", () => {
this.isLoading = true;
});
this.wavesurfer.on("ready", () => {
this.isLoading = false;
});
We've also added a Quasar notification (snackbar) whenever there's an error. This is made super easy in Quasar using this.$q.notify
from anywhere in your code.
A little design
To finalize the design, we'll add a nice background image and some styling on the waveform.
I've used James Owen's picture as the background. Save the picture as audio.png
under assets
folder, and set it as our main div's background using following css:
.controls {
background-color: white;
}
.audio-container {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)),
url("../assets/audio.jpg") no-repeat center;
background-size: cover;
}
And some styling for the waveform using WaveSurfer options:
this.wavesurfer = WaveSurfer.create({
container: "#waveform",
hideScrollbar: true,
waveColor: "white",
progressColor: "hsla(200, 100%, 30%, 0.5)",
cursorColor: "#fff",
barWidth: 3
});
And that's it!
Summary
I've found Quasar to be a very rapid way of developing a well designed mobile / desktop / web app using Vue, with very little CSS. The CLI is also pretty awesome, and saves a lot of Cordova configuration headache.
WaveSurfer can be customized and has a lot of plugins. We've loaded local files for demonstration, but it can also fetch remote urls from your server.
Links
- Full source code: GitHub
- Ask a question / drop a line on twitter at: @johnnymakestuff
- Quasar
- WaveSurfer
- Device mockup created with threed.io
Originally posted on my blog
Top comments (2)
Hi, thanks for the cool tutorial. I have a question, does it possible to comment on the timeline while the track is playing and have the small square block on the timeline showing there is someone comment on that particular time? Just like soundcloud.
You're welcome :)
I don't think that is implemented in wavesurfer, but they have a plugin system, so you can write your own plugin that does that :)
Someone wrote a timeline plugin, so you can riff off of that maybe wavesurfer-js.org/plugins/timeline...