Advertisement
PhoneGap

Build an AudioPlayer with PhoneGap: Application Logic

by

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.

Related Posts
  • Code
    Android SDK
    Create a Music Player on Android: User Controls0d63m preview image@2x
    We are building a simple music player app for Android in this series. So far, we have presented a list of the songs on the device and allowed the user to make selections from it, starting playback using the MediaPlayer class in a Service class. In this final part of the series, we will let the user control playback, including skipping to the next and previous tracks, fast-forwarding, rewinding, playing, pausing, and seeking to particular points in the track. We will also display a notification during playback so that the user can jump back to the music player after using other apps.Read More…
  • Code
    Android SDK
    Create a Music Player on Android: Project Setup0d63m preview image@2x
    The Android platform provides resources for handling media playback, which your apps can use to create an interface between the user and their music files. In this tutorial series, we will create a basic music player application for Android. The app will present a list of songs on the user device, so that the user can select songs to play. The app will also present controls for interacting with playback and will continue playing when the user moves away from the app, with a notification displayed while playback elapses.Read More…
  • Code
    JavaScript & AJAX
    Connect 4 With Socket.ioSocket io wide retina preview
    Today we'll see how we can use Node.js and Socket.io to create a multiplayer Connect 4 style game.Read More…
  • Code
    JavaScript & AJAX
    Working With IndexedDB - Part 3Indexeddb retina preview
    Welcome to the final part of my IndexedDB series. When I began this series my intent was to explain a technology that is not always the most... friendly one to work with. In fact, when I first tried working with IndexedDB, last year, my initial reaction was somewhat negative ("Somewhat negative" much like the Universe is "somewhat old."). It's been a long journey, but I finally feel somewhat comfortable working with IndexedDB and I respect what it allows. It is still a technology that can't be used everywhere (it sadly missed being added to iOS7), but I truly believe it is a technology folks can learn and make use of today. In this final article, we're going to demonstrate some additional concepts that build upon the "full" demo we built in the last article. To be clear, you must be caught up on the series or this entry will be difficult to follow, so you may also want to check out part one.Read More…
  • Code
    JavaScript & AJAX
    Working With IndexedDB - Part 2Indexeddb retina preview
    Welcome to the second part of my IndexedDB article. I strongly recommend reading the first article in this series, as I'll be assuming you are familiar with all the concepts covered so far. In this article, we're going to wrap up the CRUD aspects we didn't finish before (specifically updating and deleting content), and then demonstrate a real world application that we will use to demonstrate other concepts in the final article.Read More…
  • Code
    PhoneGap
    PhoneGap: Build a Feed Reader - Application LogicAudero reader preview2
    This is the second part of the series about Audero Feed Reader. In this article, we'll delve into the business logic of our application and provide additional background on the plugins and API used for our project.Read More…