Advertisement
  1. Code
  2. PhoneGap

Build an AudioPlayer with PhoneGap: Application Logic

Scroll to top
Read Time: 13 min

This is the second part of the series about Audero Audio Player. In this article, we're going to create the business logic of our player. I'll also explain some of the Cordova APIs that were introduced in the previous article.


Series Overview


Creating the Player

In this section, I'll show you the class called Player, which let us play, stop, rewind and fast-forward. The class relies heavily on the Media API; without its methods, our player will be completely useless. In addition to the Media API, this class takes advantage of the alert() method of the Notification API. The look of the alert varies among platforms. Most of the supported operating systems use a native dialog box but others, like Bada 2.X, use the classic browser's alert() function, which is less customizable. The former method accepts up to four parameters:

  1. message: A string containing the message to show
  2. alertCallback: A callback to invoke when the alert dialog is dismissed
  3. title: The title of the dialog (the default value is "Alert")
  4. buttonName: The button's text included in the dialog (the default value is "OK")

Bear in mind that Windows Phone 7 ignores the button name and always uses the default. Windows Phone 7 and 8 don't have a built-in browser alert, so if you want to use alert('message');, you have to assign window.alert = navigator.notification.alert.

Now that I've explained the APIs used by Player, we can take a look at how it's made. We have three properties:

  • media: the reference to the current sound object
  • mediaTimer: which will contain a unique interval ID created using the setInterval() function that we'll pass to clearInterval() to stop the sound's timer
  • isPlaying: a variable that specifies if the current sound is playing or not. In addition to the property, the class has several methods.

The initMedia() method initializes the media property with a Media object that represents the sound selected by the user. The latter is notified using the Notification API in case of error. The aim of the playPause, stop(), and seekPosition() methods should be obvious, so I'll move on. The resetLayout() and changePlayButton() methods are very simple. They are used to reset or update the player's layout according to the action performed by the user. The last remaining method is updateSliderPosition(),  which is similar to the time slider. The latter has zero (the beginning of the slider) as the default value for the current position, set using the value="0" attribute. This must be updated accordingly while the sound is playing to give to the user visual feedback concerning elapsed playing time.

We've uncovered all the details of this class, so here is the source code of the file:

1
2
var Player = {
3
   media: null,
4
   mediaTimer: null,
5
   isPlaying: false,
6
   initMedia: function(path) {
7
      Player.media = new Media(
8
         path,
9
         function() {
10
            console.log('Media file read succesfully');
11
            if (Player.media !== null)
12
               Player.media.release();
13
            Player.resetLayout();
14
         },
15
         function(error) {
16
            navigator.notification.alert(
17
               'Unable to read the media file.',
18
               function(){},
19
               'Error'
20
            );
21
            Player.changePlayButton('play');
22
            console.log('Unable to read the media file (Code): ' + error.code);
23
         }
24
      );
25
   },
26
   playPause: function(path) {
27
      if (Player.media === null)
28
         Player.initMedia(path);
29
30
      if (Player.isPlaying === false)
31
      {
32
         Player.media.play();
33
         Player.mediaTimer = setInterval(
34
            function() {
35
               Player.media.getCurrentPosition(
36
                  function(position) {
37
                     if (position > -1)
38
                     {
39
                        $('#media-played').text(Utility.formatTime(position));
40
                        Player.updateSliderPosition(position);
41
                     }
42
                  },
43
                  function(error) {
44
                     console.log('Unable to retrieve media position: ' + error.code);
45
                     $('#media-played').text(Utility.formatTime(0));
46
                  }
47
               );
48
            },
49
            1000
50
         );
51
         var counter = 0;
52
         var timerDuration = setInterval(
53
            function() {
54
               counter++;
55
               if (counter > 20)
56
                  clearInterval(timerDuration);
57
58
               var duration = Player.media.getDuration();
59
               if (duration > -1)
60
               {
61
                  clearInterval(timerDuration);
62
                  $('#media-duration').text(Utility.formatTime(duration));
63
                  $('#time-slider').attr('max', Math.round(duration));
64
                  $('#time-slider').slider('refresh');
65
               }
66
               else
67
                  $('#media-duration').text('Unknown');
68
            },
69
            100
70
         );
71
72
         Player.changePlayButton('pause');
73
      }
74
      else
75
      {
76
         Player.media.pause();
77
         clearInterval(Player.mediaTimer);
78
         Player.changePlayButton('play');
79
      }
80
      Player.isPlaying = !Player.isPlaying;
81
   },
82
   stop: function() {
83
      if (Player.media !== null)
84
      {
85
         Player.media.stop();
86
         Player.media.release();
87
      }
88
      clearInterval(Player.mediaTimer);
89
      Player.media = null;
90
      Player.isPlaying = false;
91
      Player.resetLayout();
92
   },
93
   resetLayout: function() {
94
      $('#media-played').text(Utility.formatTime(0));
95
      Player.changePlayButton('play');
96
      Player.updateSliderPosition(0);
97
   },
98
   updateSliderPosition: function(seconds) {
99
      var $slider = $('#time-slider');
100
101
      if (seconds < $slider.attr('min'))
102
         $slider.val($slider.attr('min'));
103
      else if (seconds > $slider.attr('max'))
104
         $slider.val($slider.attr('max'));
105
      else
106
         $slider.val(Math.round(seconds));
107
108
      $slider.slider('refresh');
109
   },
110
   seekPosition: function(seconds) {
111
      if (Player.media === null)
112
         return;
113
114
      Player.media.seekTo(seconds * 1000);
115
      Player.updateSliderPosition(seconds);
116
   },
117
   changePlayButton: function(imageName) {
118
      var background = $('#player-play')
119
      .css('background-image')
120
      .replace('url(', '')
121
      .replace(')', '');
122
123
      $('#player-play').css(
124
         'background-image',
125
         'url(' + background.replace(/images\/.*\.png$/, 'images/' + imageName + '.png') + ')'
126
      );
127
   }
128
};

Managing the Audio Files

This section illustrates the AppFile class that will be used to create, delete, and load the sounds using the Web Storage API. This API has two areas, Session and Local, but Cordova uses the latter. All the sounds are stored in an item titled "files" as you can see by looking at the _tableName properties.

Please note that this API is only able to store basic data. Therefore, to fit our need to store objects, we'll use the JSON format. JavaScript has a class to deal with this format called JSON. It uses the methods parse() to parse a string and recreate the appropriate data, and stringify() to convert the object in a string. As a final note, I won't be using the dot notation of the API because Windows Phone 7 doesn’t support it, so we'll use the setItem() and getItem() methods to ensure compatibility for all devices.

Now that you have an overview of how we'll store the data, let's talk about the data that we need to save. The only information that we need for each found sound is the name (name property) and an absolute path (fullPath property). The AppFile class also has a "constant", called EXTENSIONS, where we'll set the extensions that will be tested against each file. If they match up, the file will be collected by the application. We have a method to add a file (addFile()), one method to delete a file (deleteFile()), one method that deletes the whole database (deleteFiles()), and, lastly, two methods that retrieve the file from the database: getAppFiles() to retrieve all the files, and getAppFile() to retrieve just one. The class also has four comparison methods, two static (compare() and compareIgnoreCase()) and two non-static (compareTo() and compareToIgnoreCase()). The last method is the one used to retrieve the index of a certain file, getIndex(). The AppFile class enables you to perform all the basic operations you may need.

The code that implements what we've discussed can be read here:

1
2
function AppFile(name, fullPath)
3
{
4
   var _db = window.localStorage;
5
   var _tableName = 'files';
6
7
   this.name = name;
8
   this.fullPath = fullPath;
9
10
   this.save = function(files)
11
   {
12
      _db.setItem(_tableName, JSON.stringify(files));
13
   }
14
15
   this.load = function()
16
   {
17
      return JSON.parse(_db.getItem(_tableName));
18
   }
19
}
20
21
AppFile.prototype.addFile = function()
22
{
23
   var index = AppFile.getIndex(this.fullPath);
24
   var files = AppFile.getAppFiles();
25
26
   if (index === false)
27
      files.push(this);
28
   else
29
      files[index] = this;
30
31
   this.save(files);
32
};
33
34
AppFile.prototype.deleteFile = function()
35
{
36
   var index = AppFile.getIndex(this.fullPath);
37
   var files = AppFile.getAppFiles();
38
   if (index !== false)
39
   {
40
      files.splice(index, 1);
41
      this.save(files);
42
   }
43
44
   return files;
45
};
46
47
AppFile.prototype.compareTo = function(other)
48
{
49
   return AppFile.compare(this, other);
50
};
51
52
AppFile.prototype.compareToIgnoreCase = function(other)
53
{
54
   return AppFile.compareIgnoreCase(this, other);
55
};
56
57
AppFile.EXTENSIONS = ['.mp3', '.wav', '.m4a'];
58
59
AppFile.compare = function(appFile, other)
60
{
61
   if (other == null)
62
      return 1;
63
   else if (appFile == null)
64
      return -1;
65
66
   return appFile.name.localeCompare(other.name);
67
};
68
69
AppFile.compareIgnoreCase = function(appFile, other)
70
{
71
   if (other == null)
72
      return 1;
73
   else if (appFile == null)
74
      return -1;
75
76
   return appFile.name.toUpperCase().localeCompare(other.name.toUpperCase());
77
};
78
79
AppFile.getAppFiles = function()
80
{
81
   var files = new AppFile().load();
82
   return (files === null) ? [] : files;
83
};
84
85
AppFile.getAppFile = function(path)
86
{
87
   var index = AppFile.getIndex(path);
88
   if (index === false)
89
      return null;
90
   else
91
   {
92
      var file = AppFile.getAppFiles()[index];
93
      return new AppFile(file.name, file.fullPath);
94
   }
95
};
96
97
AppFile.getIndex = function(path)
98
{
99
   var files = AppFile.getAppFiles();
100
   for(var i = 0; i < files.length; i++)
101
   {
102
      if (files[i].fullPath.toUpperCase() === path.toUpperCase())
103
         return i;
104
   }
105
106
   return false;
107
};
108
109
AppFile.deleteFiles = function()
110
{
111
   new AppFile().save([]);
112
};

The Utility Class

The utility.js file is very short and easy to understand. It only has two methods. One is used to convert milliseconds into a formatted string that will be shown in the player, while the other is a JavaScript implementation of the well known Java method endsWith.

Here is the source:

1
2
var Utility = {
3
   formatTime: function(milliseconds) {
4
      if (milliseconds <= 0)
5
         return '00:00';
6
7
      var seconds = Math.round(milliseconds);
8
      var minutes = Math.floor(seconds / 60);
9
      if (minutes < 10)
10
         minutes = '0' + minutes;
11
12
      seconds = seconds % 60;
13
      if (seconds < 10)
14
         seconds = '0' + seconds;
15
16
      return minutes + ':' + seconds;
17
   },
18
   endsWith: function(string, suffix) {
19
      return string.indexOf(suffix, string.length - suffix.length) !== -1;
20
   }
21
};

Putting it All Together

This section discusses the last JavaScript file of the project, application.js, which contains the Application class. Its aim is to attach events to the app page's elements. Those events will take advantage of the classes we've seen thus far and enable the player to work properly.

The code of the illustrated function is listed below:

1
2
var Application = {
3
   initApplication: function() {
4
      $(document).on(
5
         'pageinit',
6
         '#files-list-page',
7
         function()
8
         {
9
            Application.initFilesListPage();
10
         }
11
      );
12
      $(document).on(
13
         'pageinit',
14
         '#aurelio-page',
15
         function()
16
         {
17
            Application.openLinksInApp();
18
         }
19
      );
20
      $(document).on(
21
         'pagechange',
22
         function(event, properties)
23
         {
24
            if (properties.absUrl === $.mobile.path.makeUrlAbsolute('player.html'))
25
            {
26
               Application.initPlayerPage(
27
                  JSON.parse(properties.options.data.file)
28
               );
29
            }
30
         }
31
      );
32
   },
33
   initFilesListPage: function() {
34
      $('#update-button').click(
35
         function()
36
         {
37
            $('#waiting-popup').popup('open');
38
            setTimeout(function(){
39
               Application.updateMediaList();
40
            }, 150);
41
         }
42
      );
43
      $(document).on('endupdate', function(){
44
         Application.createFilesList('files-list', AppFile.getAppFiles());
45
         $('#waiting-popup').popup('close');
46
      });
47
      Application.createFilesList('files-list', AppFile.getAppFiles());
48
   },
49
   initPlayerPage: function(file) {
50
      Player.stop();
51
      $('#media-name').text(file.name);
52
      $('#media-path').text(file.fullPath);
53
      $('#player-play').click(function() {
54
         Player.playPause(file.fullPath);
55
      });
56
      $('#player-stop').click(Player.stop);
57
      $('#time-slider').on('slidestop', function(event) {
58
         Player.seekPosition(event.target.value);
59
      });
60
   },
61
   updateIcons: function()
62
   {
63
      if ($(window).width() > 480)
64
      {
65
         $('a[data-icon], button[data-icon]').each(function() {
66
            $(this).removeAttr('data-iconpos');
67
         });
68
      }
69
      else
70
      {
71
         $('a[data-icon], button[data-icon]').each(function() {
72
            $(this).attr('data-iconpos', 'notext');
73
         });
74
      }
75
   },
76
   openLinksInApp: function()
77
   {
78
      $("a[target=\"_blank\"]").on('click', function(event) {
79
         event.preventDefault();
80
         window.open($(this).attr('href'), '_target');
81
      });
82
   },
83
   updateMediaList: function() {
84
      window.requestFileSystem(
85
         LocalFileSystem.PERSISTENT,
86
         0,
87
         function(fileSystem){
88
            var root = fileSystem.root;
89
            AppFile.deleteFiles();
90
            Application.collectMedia(root.fullPath, true);
91
         },
92
         function(error){
93
            console.log('File System Error: ' + error.code);
94
         }
95
      );
96
   },
97
   collectMedia: function(path, recursive, level) {
98
      if (level === undefined)
99
         level = 0;
100
      var directoryEntry = new DirectoryEntry('', path);
101
      if(!directoryEntry.isDirectory) {
102
         console.log('The provided path is not a directory');
103
         return;
104
      }
105
      var directoryReader = directoryEntry.createReader();
106
      directoryReader.readEntries(
107
         function (entries) {
108
            var appFile;
109
            var extension;
110
            for (var i = 0; i < entries.length; i++) {
111
               if (entries[i].name === '.')
112
                  continue;
113
114
               extension = entries[i].name.substr(entries[i].name.lastIndexOf('.'));
115
               if (entries[i].isDirectory === true && recursive === true)
116
                  Application.collectMedia(entries[i].fullPath, recursive, level + 1);
117
               else if (entries[i].isFile === true && $.inArray(extension, AppFile.EXTENSIONS) >= 0)
118
               {
119
                  appFile = new AppFile(entries[i].name, entries[i].fullPath);
120
                  appFile.addFile();
121
                  console.log('File saved: ' + entries[i].fullPath);
122
               }
123
            }
124
         },
125
         function(error) {
126
            console.log('Unable to read the directory. Errore: ' + error.code);
127
         }
128
      );
129
130
      if (level === 0)
131
         $(document).trigger('endupdate');
132
      console.log('Current path analized is: ' + path);
133
   },
134
   createFilesList: function(idElement, files)
135
   {
136
      $('#' + idElement).empty();
137
138
      if (files == null || files.length == 0)
139
      {
140
         $('#' + idElement).append('<p>No files to show. Would you consider a files update (top right button)?</p>');
141
         return;
142
      }
143
144
      function getPlayHandler(file) {
145
         return function playHandler() {
146
            $.mobile.changePage(
147
               'player.html',
148
               {
149
                  data: {
150
                     file: JSON.stringify(file)
151
                  }
152
               }
153
            );
154
         };
155
      }
156
157
      function getDeleteHandler(file) {
158
         return function deleteHandler() {
159
            var oldLenght = AppFile.getAppFiles().length;
160
            var $parentUl = $(this).closest('ul');
161
162
            file = new AppFile('', file.fullPath);
163
            file.deleteFile();
164
            if (oldLenght === AppFile.getAppFiles().length + 1)
165
            {
166
               $(this).closest('li').remove();
167
               $parentUl.listview('refresh');
168
            }
169
            else
170
            {
171
               console.log('Media not deleted. Something gone wrong.');
172
               navigator.notification.alert(
173
                  'Media not deleted. Something gone wrong so please try again.',
174
                  function(){},
175
                  'Error'
176
               );
177
            }
178
         };
179
      }
180
181
      var $listElement, $linkElement;
182
      files.sort(AppFile.compareIgnoreCase);
183
      for(var i = 0; i < files.length; i++)
184
      {
185
         $listElement = $('<li>');
186
         $linkElement = $('<a>');
187
         $linkElement
188
         .attr('href', '#')
189
         .text(files[i].name)
190
         .click(getPlayHandler(files[i]));
191
192
         // Append the link to the <li> element

193
         $listElement.append($linkElement);
194
195
         $linkElement = $('<a>');
196
         $linkElement
197
         .attr('href', '#')
198
         .text('Delete')
199
         .click(getDeleteHandler(files[i]));
200
201
         // Append the link to the <li> element

202
         $listElement.append($linkElement);
203
204
         // Append the <li> element to the <ul> element

205
         $('#' + idElement).append($listElement);
206
      }
207
      $('#' + idElement).listview('refresh');
208
   }
209
};

Managing External Links

In the previous part of this series, I mentioned that an interesting point of the credits page was the attribute target="_blank" applied to the links. This section will explain why the openLinksInApp() method of the Application class makes sense.

Once upon a time, Cordova used to open external links in the same Cordova WebView that was running the application. When a link was open and the user clicked the "back" button, the last displayed page was shown exactly as it was before the user left it. In the newer version, this has changed. Nowadays, the external links are opened, by default, using the Cordova WebView if the URL is in your app's whitelist. URLs that aren't on your whitelist are opened using the InAppBrowser API. If you don't manage the links in the right way, or if the user taps a link that is shown in the InAppBrowser or the system and then chooses to go back, all the jQuery Mobile enhancements are lost. This behavior happens because the CSS and JavaScript files are loaded by the main page, and the following ones are loaded using AJAX. Before uncovering the solution, let's take a look at what's the InAppBrowser.

The InAppBrowser is a web-browser that is shown in your app when you use the window.open call.

This API has three methods:

  • addEventListener(): Allows you to listen for three events (loadstart, loadstop, and exit) and attach a function that runs as soon as those events are fired
  • removeEventListener(): Removes a previously-attached listener.
  • close(): Used to close the InAppBrowser window.

So, what's the solution? The aim of the openLinksInApp() function, coupled with the whitelist specified in the configuration file, is to catch the clicks on all the external links recognized by using the target="_blank" attribute, and open them using the window.open() method. With this technique, we'll avoid the problem described, and our player will continue to look and work as expected.


Next Part

In the third and last installment of this series, we'll see the last remaining files so that you can complete the project and play around with it.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.