Advertisement

Build an AudioPlayer with PhoneGap: Application Logic

by
Student iconAre you a student? Get a yearly Tuts+ subscription for $45 →

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:

var Player = {
   media: null,
   mediaTimer: null,
   isPlaying: false,
   initMedia: function(path) {
      Player.media = new Media(
         path,
         function() {
            console.log('Media file read succesfully');
            if (Player.media !== null)
               Player.media.release();
            Player.resetLayout();
         },
         function(error) {
            navigator.notification.alert(
               'Unable to read the media file.',
               function(){},
               'Error'
            );
            Player.changePlayButton('play');
            console.log('Unable to read the media file (Code): ' + error.code);
         }
      );
   },
   playPause: function(path) {
      if (Player.media === null)
         Player.initMedia(path);

      if (Player.isPlaying === false)
      {
         Player.media.play();
         Player.mediaTimer = setInterval(
            function() {
               Player.media.getCurrentPosition(
                  function(position) {
                     if (position > -1)
                     {
                        $('#media-played').text(Utility.formatTime(position));
                        Player.updateSliderPosition(position);
                     }
                  },
                  function(error) {
                     console.log('Unable to retrieve media position: ' + error.code);
                     $('#media-played').text(Utility.formatTime(0));
                  }
               );
            },
            1000
         );
         var counter = 0;
         var timerDuration = setInterval(
            function() {
               counter++;
               if (counter > 20)
                  clearInterval(timerDuration);

               var duration = Player.media.getDuration();
               if (duration > -1)
               {
                  clearInterval(timerDuration);
                  $('#media-duration').text(Utility.formatTime(duration));
                  $('#time-slider').attr('max', Math.round(duration));
                  $('#time-slider').slider('refresh');
               }
               else
                  $('#media-duration').text('Unknown');
            },
            100
         );

         Player.changePlayButton('pause');
      }
      else
      {
         Player.media.pause();
         clearInterval(Player.mediaTimer);
         Player.changePlayButton('play');
      }
      Player.isPlaying = !Player.isPlaying;
   },
   stop: function() {
      if (Player.media !== null)
      {
         Player.media.stop();
         Player.media.release();
      }
      clearInterval(Player.mediaTimer);
      Player.media = null;
      Player.isPlaying = false;
      Player.resetLayout();
   },
   resetLayout: function() {
      $('#media-played').text(Utility.formatTime(0));
      Player.changePlayButton('play');
      Player.updateSliderPosition(0);
   },
   updateSliderPosition: function(seconds) {
      var $slider = $('#time-slider');

      if (seconds < $slider.attr('min'))
         $slider.val($slider.attr('min'));
      else if (seconds > $slider.attr('max'))
         $slider.val($slider.attr('max'));
      else
         $slider.val(Math.round(seconds));

      $slider.slider('refresh');
   },
   seekPosition: function(seconds) {
      if (Player.media === null)
         return;

      Player.media.seekTo(seconds * 1000);
      Player.updateSliderPosition(seconds);
   },
   changePlayButton: function(imageName) {
      var background = $('#player-play')
      .css('background-image')
      .replace('url(', '')
      .replace(')', '');

      $('#player-play').css(
         'background-image',
         'url(' + background.replace(/images\/.*\.png$/, 'images/' + imageName + '.png') + ')'
      );
   }
};

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:

function AppFile(name, fullPath)
{
   var _db = window.localStorage;
   var _tableName = 'files';

   this.name = name;
   this.fullPath = fullPath;

   this.save = function(files)
   {
      _db.setItem(_tableName, JSON.stringify(files));
   }

   this.load = function()
   {
      return JSON.parse(_db.getItem(_tableName));
   }
}

AppFile.prototype.addFile = function()
{
   var index = AppFile.getIndex(this.fullPath);
   var files = AppFile.getAppFiles();

   if (index === false)
      files.push(this);
   else
      files[index] = this;

   this.save(files);
};

AppFile.prototype.deleteFile = function()
{
   var index = AppFile.getIndex(this.fullPath);
   var files = AppFile.getAppFiles();
   if (index !== false)
   {
      files.splice(index, 1);
      this.save(files);
   }

   return files;
};

AppFile.prototype.compareTo = function(other)
{
   return AppFile.compare(this, other);
};

AppFile.prototype.compareToIgnoreCase = function(other)
{
   return AppFile.compareIgnoreCase(this, other);
};

AppFile.EXTENSIONS = ['.mp3', '.wav', '.m4a'];

AppFile.compare = function(appFile, other)
{
   if (other == null)
      return 1;
   else if (appFile == null)
      return -1;

   return appFile.name.localeCompare(other.name);
};

AppFile.compareIgnoreCase = function(appFile, other)
{
   if (other == null)
      return 1;
   else if (appFile == null)
      return -1;

   return appFile.name.toUpperCase().localeCompare(other.name.toUpperCase());
};

AppFile.getAppFiles = function()
{
   var files = new AppFile().load();
   return (files === null) ? [] : files;
};

AppFile.getAppFile = function(path)
{
   var index = AppFile.getIndex(path);
   if (index === false)
      return null;
   else
   {
      var file = AppFile.getAppFiles()[index];
      return new AppFile(file.name, file.fullPath);
   }
};

AppFile.getIndex = function(path)
{
   var files = AppFile.getAppFiles();
   for(var i = 0; i < files.length; i++)
   {
      if (files[i].fullPath.toUpperCase() === path.toUpperCase())
         return i;
   }

   return false;
};

AppFile.deleteFiles = function()
{
   new AppFile().save([]);
};

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:

var Utility = {
   formatTime: function(milliseconds) {
      if (milliseconds <= 0)
         return '00:00';

      var seconds = Math.round(milliseconds);
      var minutes = Math.floor(seconds / 60);
      if (minutes < 10)
         minutes = '0' + minutes;

      seconds = seconds % 60;
      if (seconds < 10)
         seconds = '0' + seconds;

      return minutes + ':' + seconds;
   },
   endsWith: function(string, suffix) {
      return string.indexOf(suffix, string.length - suffix.length) !== -1;
   }
};

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:

var Application = {
   initApplication: function() {
      $(document).on(
         'pageinit',
         '#files-list-page',
         function()
         {
            Application.initFilesListPage();
         }
      );
      $(document).on(
         'pageinit',
         '#aurelio-page',
         function()
         {
            Application.openLinksInApp();
         }
      );
      $(document).on(
         'pagechange',
         function(event, properties)
         {
            if (properties.absUrl === $.mobile.path.makeUrlAbsolute('player.html'))
            {
               Application.initPlayerPage(
                  JSON.parse(properties.options.data.file)
               );
            }
         }
      );
   },
   initFilesListPage: function() {
      $('#update-button').click(
         function()
         {
            $('#waiting-popup').popup('open');
            setTimeout(function(){
               Application.updateMediaList();
            }, 150);
         }
      );
      $(document).on('endupdate', function(){
         Application.createFilesList('files-list', AppFile.getAppFiles());
         $('#waiting-popup').popup('close');
      });
      Application.createFilesList('files-list', AppFile.getAppFiles());
   },
   initPlayerPage: function(file) {
      Player.stop();
      $('#media-name').text(file.name);
      $('#media-path').text(file.fullPath);
      $('#player-play').click(function() {
         Player.playPause(file.fullPath);
      });
      $('#player-stop').click(Player.stop);
      $('#time-slider').on('slidestop', function(event) {
         Player.seekPosition(event.target.value);
      });
   },
   updateIcons: function()
   {
      if ($(window).width() > 480)
      {
         $('a[data-icon], button[data-icon]').each(function() {
            $(this).removeAttr('data-iconpos');
         });
      }
      else
      {
         $('a[data-icon], button[data-icon]').each(function() {
            $(this).attr('data-iconpos', 'notext');
         });
      }
   },
   openLinksInApp: function()
   {
      $("a[target=\"_blank\"]").on('click', function(event) {
         event.preventDefault();
         window.open($(this).attr('href'), '_target');
      });
   },
   updateMediaList: function() {
      window.requestFileSystem(
         LocalFileSystem.PERSISTENT,
         0,
         function(fileSystem){
            var root = fileSystem.root;
            AppFile.deleteFiles();
            Application.collectMedia(root.fullPath, true);
         },
         function(error){
            console.log('File System Error: ' + error.code);
         }
      );
   },
   collectMedia: function(path, recursive, level) {
      if (level === undefined)
         level = 0;
      var directoryEntry = new DirectoryEntry('', path);
      if(!directoryEntry.isDirectory) {
         console.log('The provided path is not a directory');
         return;
      }
      var directoryReader = directoryEntry.createReader();
      directoryReader.readEntries(
         function (entries) {
            var appFile;
            var extension;
            for (var i = 0; i < entries.length; i++) {
               if (entries[i].name === '.')
                  continue;

               extension = entries[i].name.substr(entries[i].name.lastIndexOf('.'));
               if (entries[i].isDirectory === true && recursive === true)
                  Application.collectMedia(entries[i].fullPath, recursive, level + 1);
               else if (entries[i].isFile === true && $.inArray(extension, AppFile.EXTENSIONS) >= 0)
               {
                  appFile = new AppFile(entries[i].name, entries[i].fullPath);
                  appFile.addFile();
                  console.log('File saved: ' + entries[i].fullPath);
               }
            }
         },
         function(error) {
            console.log('Unable to read the directory. Errore: ' + error.code);
         }
      );

      if (level === 0)
         $(document).trigger('endupdate');
      console.log('Current path analized is: ' + path);
   },
   createFilesList: function(idElement, files)
   {
      $('#' + idElement).empty();

      if (files == null || files.length == 0)
      {
         $('#' + idElement).append('<p>No files to show. Would you consider a files update (top right button)?</p>');
         return;
      }

      function getPlayHandler(file) {
         return function playHandler() {
            $.mobile.changePage(
               'player.html',
               {
                  data: {
                     file: JSON.stringify(file)
                  }
               }
            );
         };
      }

      function getDeleteHandler(file) {
         return function deleteHandler() {
            var oldLenght = AppFile.getAppFiles().length;
            var $parentUl = $(this).closest('ul');

            file = new AppFile('', file.fullPath);
            file.deleteFile();
            if (oldLenght === AppFile.getAppFiles().length + 1)
            {
               $(this).closest('li').remove();
               $parentUl.listview('refresh');
            }
            else
            {
               console.log('Media not deleted. Something gone wrong.');
               navigator.notification.alert(
                  'Media not deleted. Something gone wrong so please try again.',
                  function(){},
                  'Error'
               );
            }
         };
      }

      var $listElement, $linkElement;
      files.sort(AppFile.compareIgnoreCase);
      for(var i = 0; i < files.length; i++)
      {
         $listElement = $('<li>');
         $linkElement = $('<a>');
         $linkElement
         .attr('href', '#')
         .text(files[i].name)
         .click(getPlayHandler(files[i]));

         // Append the link to the <li> element
         $listElement.append($linkElement);

         $linkElement = $('<a>');
         $linkElement
         .attr('href', '#')
         .text('Delete')
         .click(getDeleteHandler(files[i]));

         // Append the link to the <li> element
         $listElement.append($linkElement);

         // Append the <li> element to the <ul> element
         $('#' + idElement).append($listElement);
      }
      $('#' + idElement).listview('refresh');
   }
};

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