Working with MP3s, ID3, and PhoneGap/Cordova (2)

This post is more than 2 years old.

Yesterday I blogged about using MP3s and ID3 information in a PhoneGap/Cordova application. Today I've taken the initial proof of concept I built in that demo and updated to make use of the Ionic framework. I've also a few other features to make the application a bit more applicable to real world usage. Finally, I've also uploaded it my GitHub repo (along with a copy of the last version) for you to use in your own applications. Before we get into the code, let's take a look at the visual updates.

The first update was the addition of a spinner dialog. I used the spinner from ngCordova.

Screen Shot 2015-04-30 at 4.06.16 PM

This will display while the code is parsing the MP3s for their ID3 information. When done, a list is displayed:

Screen Shot 2015-04-30 at 4.09.50 PM

Yeah, not very colorful, I really need to add something to the header to make it prettier. But you get the idea. Then when an item is selected, you get a nice Ionic card display:

Screen Shot 2015-04-30 at 4.10.37 PM

Now let's break down the code - and remember - you can download everything from the repo I'll link to at the bottom. First - the core app.js for the app:

angular.module('starter', ['ngCordova','ionic', 'starter.controllers', 'starter.services'])

.run(function($ionicPlatform) {
  $ionicPlatform.ready(function() {
    if (window.cordova && window.cordova.plugins && window.cordova.plugins.Keyboard) {
      cordova.plugins.Keyboard.hideKeyboardAccessoryBar(true);
    }
    if (window.StatusBar) {
      // org.apache.cordova.statusbar required
      StatusBar.styleLightContent();
    }
  });
})

.config(function($stateProvider, $urlRouterProvider) {

  $stateProvider

  $stateProvider
  .state('list', {
    url: '/',
    templateUrl: 'templates/list.html',
    controller: 'ListCtrl'
  })
  .state('list-detail', {
      url: '/item/:itemId',
      templateUrl: 'templates/detail.html',
      controller: 'DetailCtrl'
  });

  // if none of the above states are matched, use this as the fallback
  $urlRouterProvider.otherwise('/');
  
});

The only thing of note here really is the use of $stateProvider to setup the various states of my app - which in this case is either a list of MP3s or a detail. Now let's look at the controller.

angular.module('starter.controllers', [])

.controller('ListCtrl', function($scope, MP3Service, $cordovaSpinnerDialog) {
	console.log('ListCtrl loaded');

	document.addEventListener('deviceready', function () {

		console.log('begin to get stuff');
		$cordovaSpinnerDialog.show("Loading...","", true);
	
		MP3Service.getAll().then(function(results) {
			$cordovaSpinnerDialog.hide();
			$scope.content = results;
		});

	});
		
})
.controller('DetailCtrl', function($scope, $stateParams, MP3Service) {
	console.log('DetailCtrl loaded');
	$scope.detail = {};
	
	getMediaURL = function(s) {
	    if(device.platform.toLowerCase() === "android") return "/android_asset/www/" + s;
	    return s;
	}

	$scope.play = function() {
		console.log('click for '+$scope.detail.url);
		
		MP3Service.play(getMediaURL($scope.detail.url));
	};

	MP3Service.getOne($stateParams.itemId).then(function(result) {
		console.dir(result);
		result.description = "Artist: " + result.tags.artist + "<br/>" +
		 					 "Album: " + result.tags.album;
		$scope.detail = result;
	});

});

Ok, so this one is a bit more complex. The first controller, ListCtrl, handles asking a service to return a list of MP3s. It uses the spinner dialog to let the user know "stuff" is going on in the background. Once it has the data, it hides the spinner and the results are displayed. Note the deviceready listener wrapping the call. I forgot this initially and spent about an hour trying to figure out why my app wouldn't run until I did a reload in the console. Dumb, I know, but sometimes when I use Ionic I forget to remember I need deviceready in my controller.

The next controller handles fetching specific information about a MP3 as well as providing a way to play the MP3. I put that in a service as well so I could handle storing the state of the current MP3 being played.

So far so good? Ok, let's take a look at the service. Most of this is from yesterday's post.

angular.module('starter.services', [])

.factory('MP3Service', function($q,$cordovaFile) {
	
	//root of where my stuff is
	console.log('running service');
	var items = [];

	function getAll() {
		var rootFolder = cordova.file.applicationDirectory;
		var mp3Loc = 'music/';
		//where the music is
		var mp3Folder = rootFolder + 'www/' + mp3Loc;
		console.log(mp3Folder);

		var deferred = $q.defer();

		window.resolveLocalFileSystemURL(mp3Folder, function(dir) {
			var reader = dir.createReader();
			//read it
			reader.readEntries(function(entries) {
					console.log("readEntries");
					console.dir(entries);

					var data = [];

					var process = function(index, cb) {
						var entry = entries[index];
						var name = entry.name;
						entry.file(function(file) {

							ID3.loadTags(entry.name,function() {
								var tags = ID3.getAllTags(name);
								//default to filename
								var title = entry.name;
								if(tags.title) title = tags.title;
								//for now - not optimal to include music here, will change later
								data.push({name:title, tags:tags, url:mp3Loc+entry.name});
								if(index+1 < entries.length) {
									process(++index, cb);
								} else {
									cb(data);
								}
							},{
								dataReader:FileAPIReader(file)
							});

						});

					};

					process(0, function(data) {
						console.log("Done processing");
						console.dir(data);
						items = data;
						deferred.resolve(items);
					});


			});

		}, function(err) {
			deferred.reject(err);
		});


		return deferred.promise;
		
	}

	function getOne(id) {
		var deferred = $q.defer();
		deferred.resolve(items[id]);

		return deferred.promise;
	}

	var media;
	function play(l) {
		if(media) { media.stop(); media.release(); }
		media = new Media(l,function() {}, function(err) { console.dir(err);});
		media.play();
	}
	
	return {
		getAll:getAll,
		getOne:getOne,
		play:play
	};
  
});

Ok, there's a lot going on here. First - for this application I decided to ship the MP3s with the application. Now, in a real world app if you were going to do that, you wouldn't bother using an ID3 service. You would simply hard code it. That would be a heck of a lot quicker. But try to imagine an app where MP3s are downloaded after the initial install. This brings up another interesting issue. The area under www is read only, so technically you can't download there. But - and I'm not 100% sure on this - the Media plugin only supports remote URLs and local URLs under www. I could be wrong on that (and I've raised the question on the PhoneGap developer list), but... yeah. I'm not sure how the Media plugin would work with stuff outside of www. For now, I'm going to pretend it isn't an issue.

Another thing I didn't do here is caching. Since the service won't run again when you return to the app home page, I didn't need it, but I'd strongly consider adding a simple caching layer with LocalStorage. I think storing the tags for a path would be simple enough and would take maybe five minutes more work.

And that's pretty much it. You can find the full source here: https://github.com/cfjedimaster/Cordova-Examples/tree/master/mp3reader. Tomorrow I'll have yet another iteration of this demo.

Raymond Camden's Picture

About Raymond Camden

Raymond is a senior developer evangelist for Adobe. He focuses on document services, JavaScript, and enterprise cat demos. If you like this article, please consider visiting my Amazon Wishlist or donating via PayPal to show your support. You can even buy me a coffee!

Lafayette, LA https://www.raymondcamden.com

Archived Comments

Comment 1 by Dante Cervantes posted on 5/1/2015 at 3:52 AM

awesome! whit the ID3, can you get album image?

Comment 2 (In reply to #1) by Raymond Camden posted on 5/1/2015 at 11:32 AM

It is an optional part of ID3 - see the demos on the project - https://github.com/aadsm/Ja.... Also, watch this blog as my next entry in this series will demonstrate a way to do it.

Comment 3 (In reply to #2) by Dante Cervantes posted on 5/4/2015 at 12:16 AM

thanks a lot! i'll be waiting for your next entry, i really want to show the album art in my song, instead of use some web service like last.fm to do it

Comment 4 (In reply to #3) by Raymond Camden posted on 5/4/2015 at 1:03 AM

Already posted. :)

Comment 5 (In reply to #4) by Dante Cervantes posted on 5/21/2015 at 10:49 PM

thanks! can you paste here the link?

Comment 6 (In reply to #5) by Raymond Camden posted on 5/21/2015 at 11:27 PM

Oh sorry - I misread you. I *did* use last.fm.

Comment 7 by тимофей чернявский posted on 8/8/2015 at 12:08 AM

Hello! Thanks for this post!
Can You explain what is the class FileAPIReader. Ionic framework based on AngularJS wich have build-in class FileReader with different logic. Or FileAPIReader it's some kind of wrapper for FileReader?

Comment 8 (In reply to #7) by Raymond Camden posted on 8/8/2015 at 1:51 PM

I'm not quite sure what you are asking. I'm using ngCordova which has a wrapper for the Cordova File plugin.

Comment 9 (In reply to #8) by тимофей чернявский posted on 8/8/2015 at 6:12 PM

Thanks for Your reply!
Right, I'm using ngCordova too. But there is no FileAPIReader function of class or any.. in ngCordova.
I use same tools as in Your post: Ionic+ngCordova+same id3 lib. But I have android platform added instead IOS. And you example not working for me. Because specified dataReader option in your code (FileAPIReader) for ID3.loadTags function is not existed function.
I'm trying to use native FileReader class as dataReader for ID3 but not successful.

Comment 10 (In reply to #9) by Raymond Camden posted on 8/8/2015 at 6:30 PM

Where do you see "FileAPIReader"? I don't see it in my code.

Comment 11 (In reply to #10) by тимофей чернявский posted on 8/8/2015 at 7:40 PM

In article, inside MP3Service.getAll function.
And here - https://github.com/cfjedima...

Comment 12 (In reply to #11) by Raymond Camden posted on 8/10/2015 at 3:09 AM

Oh that's from the ID3 library. You can even see it in the minimized version of the library I used. Ensure you correctly loaded the ID3 library.

Comment 13 (In reply to #12) by тимофей чернявский posted on 8/11/2015 at 1:11 PM

Right!) Thanks! I really doesn't found this function!

But anyway I have problems with usage FileAPIReader function. I have not big array of files to process it id3 tags in cycle. On my android device processing of this array in cycle broke application and close it forced. I think it because inside FileAPIReader function creates new instance of FileReader that's in my case occur memory leak.

So You help me to find FileAPIReader function src and I my solution is just pass second parameter in FileAPIReader function to use single instance of FileReader for all files in cycle!

Thank You very much!

Comment 14 (In reply to #13) by Raymond Camden posted on 8/11/2015 at 9:36 PM

Glad to help. Now - you can pay me back if you want. I'm curious about your name. What origin is that? My desire to know is just plain curiosity and if you have *any* reservations about answering a personal question, do not worry!

Comment 15 (In reply to #14) by тимофей чернявский posted on 8/11/2015 at 10:57 PM

Aha) That's fun! The answer is really simple - https://en.wikipedia.org/wi...
I'm from Belarus so name written in Cyrillic because it automatically fetched while login via facebook)

Comment 16 (In reply to #15) by Raymond Camden posted on 8/11/2015 at 11:01 PM

Cool, thanks for sharing.

Comment 17 by ป๋า แพะ posted on 10/21/2015 at 8:15 AM

I try to use JavaScript-ID3-Reader in ionicframework
but when run in real android device it's alway throw java.lang.OutOfMemoryError
can you tell me what I do wrong?

entries.forEach(function(entry){
var name = entry.name;
console.log('for each');
entry.file(function(file){
console.log("entry file: "+entry.name);
ID3.loadTags(entry.name, function() {
console.log("load");
var tags = ID3.getAllTags(name);
console.log(tags);
}, {
tags: ["title","artist","album","picture"],
dataReader:FileAPIReader(file)
});
});
});

Comment 18 (In reply to #17) by Raymond Camden posted on 10/21/2015 at 10:58 AM

It means you are running out of memory. Unfortunately there isn't anything I can do about that outside of suggesting you reach out to the library owner to see if they can do something to make the reader a bit more memory friendly.

Comment 19 (In reply to #17) by тимофей чернявский posted on 11/12/2015 at 11:39 PM

I think to minimize memory usage in Your case you can create instance of FileReader out from loop and pass it into FileAPIReader function

var reader = new FileReader();

entries.forEach(function(entry){
...
ags: ["title","artist","album","picture"],
dataReader: FileAPIReader(file, reader),

...

}

I think lack of memory happens because into FileAPIReader function every time creates new instance of FileReader if it not passed.

Comment 20 by Darkyen00 posted on 5/24/2016 at 7:21 PM

I tried to do this, dear-lord android could be excruciatingly slow takes 2 seconds on desktop took 24 minutes for 200 songs on android :-| although i used `musicmetatags` as it supports passing straight file instance.

Comment 21 (In reply to #19) by Raymond Camden posted on 5/24/2016 at 7:52 PM

Looks good - thanks for sharing that.

Comment 22 (In reply to #20) by Raymond Camden posted on 5/24/2016 at 7:52 PM

Ouch. One thing to remember - at least you can cache the lookup and never do it again.

Comment 23 (In reply to #22) by Darkyen00 posted on 5/24/2016 at 10:46 PM

I am digging deeper, apparently the process was incredible amount of memory forcing the device to freeze up when using the phonegap file API. I am wondering if its the fault of the library or how phonegap implements file API.