Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Mobile / iPhone

Audiobook Player for iPhone

4.73/5 (16 votes)
8 Sep 2010CPOL9 min read 87.9K   2.4K  
An iPhone media player designed specifically for listening to audiobooks

Introduction

The iPod application on the iPhone/iPod is a decent player for listening to music or viewing video; however, when it comes to listening to audiobooks, it falls short:

  • The play/pause button is small and easy to miss. It's also co-located with the Prev/Next buttons which might be pressed unintentionlly (when listening to a book, skipping or rewinding accidentally tends to be quite frustrating).
  • There is no way to organize the books into groups (for series, type of book, etc.).
  • The timeline navigator is too small and too cumbersome to handle effectively (audiobooks tend to be large files - several hours long), and if one wishes to move a short while - seconds, it's almost impossible.
  • In a multiple-files book, iPod tends to play them in an arbitrary order (can't understand the logic).
  • There is no easy way to view the time listened so far and left to play in multiple-file books.
  • Volume level and the shuffle attribute are player properties (as opposed to book/playlist property).
  • Bookmarks are maintained only for 'audiobooks' (and not for music collections), and they are prone to unwanted changes due to ... (I don't get the logic here too, maybe the sync process spoils them).

As I tend to frequently listen to audiobooks, and as I have some programming background, I developed this 'abPlayer'.

The Application

abPlayer organizes playlists (books) in groups called 'shelves'. Each shelf can hold any number of books as well as any number of sub-shelves (no limit to the depth of shelves within shelves).

The main 'playing' form is shown below:

Playing_Text.PNG

When playing, the playing location (bookmark) is saved in 1 second intervals, and is persistent across program activation. Persistency is achieved using a SQLite database that resides in the application's 'Documents' directory. The SQLite code is covered in the 'Code' section below.

The whole screen area is used as a play/pause alternating switch - tap the screen once to play, tap again to pause.

The information displayed includes:

  • Book name
  • Media title
  • Entry (media number) / number of media files in book
  • Location / duration within the media file being played
  • Location / duration within the whole book
  • Player state

In addition, the screen area is sensitive to gestures (finger swipes):

  • horizontal - right to left: rewind 15 seconds
  • horizontal - left to right: skip forward 15 seconds
  • vertical - any direction: replace display with album artwork, or back to text - alternating

The screen below shows the same 'playing' form with artwork display:

Playing_Graphics.PNG

When the 'Properties' button is pressed, the following screen is displayed:

Properties.PNG

Using the 'slot machine'-like control, the file number as well as the location in the file can be changed with a 1 second precision. The 'shuffle' state and the playing volume are saved as book attributes, and are persistent across multiple activations of the program.

The following screen shows one of the 'Library' forms. The library form is where shelves and books and their media items are defined. In the picture below, we can see a set of shelves located in the 'Series' shelf:

Library.PNG

The 'New' button is used to create a new book/shelf. 'Add media' is used to add media items to a book. 'Del' and 'Edit' are used to delete, move, and rename an item. When adding media to a shelf, a book is automatically created. The name of the book is taken from the 'album' tag of the first media item. Selecting an entry will make it the active book (currently being played). Hitting the accessory button (the horizontal arrow like button) will navigate to a lower level - shelf: to its sub-shelves and books, book: to its media items. If a book is selected, playing starts at the playing location last played. If a media item is selected, playing starts with the selected media.

When 'New' is pressed, the 'browser' form is displayed. The browser form(s) navigate through existing media items (by category), and allows selection of media items to add to a book. Following is the first 'browser' form:

Add_Files_1.PNG

Each media item displayed in the browsed screen can be selected. 'All' selects/de-selects all (alternating). The 'browser' form with the media items is shown here:

Add-Files_2.PNG

When 'Graphics' is pressed in a 'library' form, the books artwork is displayed. The display includes all books in the active shelf (recursively). Here is the 'Graphics' form:

Graphics.PNG

To change to the Prev/Next artwork, horizontal gestures are used: right-to-left and left-to-right, respectively. That's basically it. There's more that can be described and shown, but that would spoil all the fun.

Compile/build it, install it, play with it - hope you'll enjoy it - I do.

Using the Code

There are several classes/forms in the project. I will highlight some pieces of code relating to the two classes:

  • cPersistant - is a SQLite function wrapper
  • cPlayer - is a media player encapsulating class

cPersistant can be used as the application template for a SQLite database access. cPlayer can be used as-is in any application that needs to play a media item. Both classes can be used as code samples for their respective subject.

cPersistant

SQLite routines are C based (not Obj-C), which are a nuisance when your code, as a whole, is Obj-C. More disturbing is the fact that each call involves several steps:

  • Prepare the SQL statement
  • Format it
  • Execute it
  • Release resources

I decided to write an Obj-C wrapper around the SQLite calls and make them more straightforward, readable, and short. The following code describes what I did:

Define these globals:

C++
char* errorMsg;
sqlite3* DB;

These are the error description texts when an operation fails or the SQLite database object.

Whenever an operation fails, SQLite returns (optionally) a description text in the global errorMsg above. The following routine formats this message and displays it in an alert box:

C++
-(void)showErr // display error text in an alert box
{
   if(errorMsg == nil) return; // if no error found - quit
       // create UIAlert box
       UIAlertView* alert = [[UIAlertView alloc] initWithTitle:@"DB Error" 
       message:[NSString stringWithCString:errorMsg 
       encoding:NSUTF8StringEncoding]
       delegate:nil cancelButtonTitle:@"OK" 
       otherButtonTitles:nil];
   // show it
   [alert show];
   [alert release];
}

formatSQL is a variadic function (function that takes a variable number of arguments). It takes a SQL statement as its first parameter and any number of additional parameters. Embedded in the SQL statement are '?' characters (as many as there are parameters). The function replaces each '?' with the corresponding parameter while formatting it to a string. In addition, string parameters are scanned for a single quote character that are replaced with two consecutive single quote characters so as not to confuse the SQLite syntax parser which takes string values in single quote enveloping.

C++
-(NSString*)formatSQL:(NSString*)sql argumentList:(va_list)argumentList
{
   // create an array split by the insertion char '?'
   NSArray* splitCmd = [sql componentsSeparatedByString:@"?"];
   // start off with empty result
   NSString* result = @"";
   int n = 0;
   NSObject* temp;
   NSString* str;
   // as long as there are parameters 
   // handle insertion char-? and param pairs
   while(temp = va_arg(argumentList, id))
   {
      // format NSNumber to string, take string as is
      if([temp isKindOfClass:[NSString class]])
         str = (NSString*)temp;
      else
         str = [(NSNumber*)temp stringValue];
      result = [result stringByAppendingString:[splitCmd objectAtIndex:n]];
      n++; 
      // replace ' with ''
      result = [result stringByAppendingString:[str stringByReplacingOccurrencesOfString:
                                @"'"  withString:@"''"]];
   }
   // handle last split piece and return 
   return [result stringByAppendingString:[splitCmd objectAtIndex:n]]; 
}

The executeSQL function is again a variadic function (which uses the above formatSQL function) and executes a non result-set SQL command returning a success/failure Boolean result code.

C++
-(BOOL)executeSQL:(NSString*) sql, ...
{
   // variadic syntax
   va_list argumentList;
   va_start(argumentList, sql); 
   // format the SQL
   NSString* str = [self formatSQL:sql argumentList:argumentList];
   // release va_list
   va_end(argumentList);
   // execute statement
   if(sqlite3_exec(DB, [str UTF8String], NULL, NULL, &errorMsg) != SQLITE_OK)
   {
      [self showErr];
      return NO;
   }
   return YES;
}

The createDataSet executes a SQL statement with a result-set (usually a 'select') and formats the result-set into an array of dictionary items. Each dictionary item corresponds to a row of the result-set, and each key-value set corresponds to a column where the key is the column name:

C++
-(NSArray*)createDataSet:(NSString*) sql, ...;
{
   // variadic syntax
   va_list argumentList;
   va_start(argumentList, sql); 
   // format SQL and release resources
   NSString* str = [self formatSQL:sql argumentList:argumentList];
   va_end(argumentList);
   sqlite3_stmt* compiledSQL;
   // the result array
   NSMutableArray* array = [NSMutableArray arrayWithCapacity:10];
   // sqlite prepare
   if(sqlite3_prepare_v2(DB, [str UTF8String], -1, &compiledSQL, NULL) == SQLITE_OK) 
   {
      // while there are still rows
      while(sqlite3_step(compiledSQL) == SQLITE_ROW) 
      {
         // the row result dictionary
         NSMutableDictionary* dic = [NSMutableDictionary dictionaryWithCapacity:10];
         // get number of columns
         int count = sqlite3_column_count(compiledSQL);
         // iterate through columns
         for(int n=0; n<count; n++)
         {
            NSString* name = [NSString stringWithUTF8String:(char*)sqlite3_column_name
                      compiledSQL, n)];
            id value = @"";
            // get data type
            int columnType = sqlite3_column_type(compiledSQL, n);
            // format column value based on column data type
            switch (columnType) 
            {
               case SQLITE_INTEGER:
                  value = [NSNumber numberWithInt:sqlite3_column_int(compiledSQL, n)];
                  break;
               case SQLITE_FLOAT:
                  value = [NSNumber numberWithFloat:sqlite3_column_double
						(compiledSQL, n)];
                  break;
               case SQLITE_TEXT:
                  value = [NSString stringWithUTF8String:
                               (char*)sqlite3_column_text(compiledSQL, n)];
                  break;
            }
            [dic setValue:value forKey:name];
         }
         // add the row dictionary to the result array
         [array addObject:dic];
      }
   }
   // release resources
   sqlite3_finalize(compiledSQL);
   return array;
}

From here on, accessing the SQLite database is free of SQLite syntax and complexities, and is straightforward. Moreover, it allows the bulk of the code to use terms and lingo that's consistent with the application subject matter (in my case: shelves, books, etc.). The following code is an example of the use of the wrapper functions:

C++
-(NSMutableArray*)books:(NSString*)inShelf
{
   // result array of book names
   NSMutableArray* array = [NSMutableArray arrayWithCapacity:20];
   // select sql - 1 parameter
   NSString* const sql = @"SELECT BOOK FROM BOOKS WHERE SHELF=? ORDER BY BOOK";
   // get the data set
   NSArray* ds = [self createDataSet:sql, inShelf, nil];
   // iterate through the data set and insert books names into the result array
   for(NSDictionary* dsDic in ds)
      [array addObject:[dsDic objectForKey:@"BOOK"]];
   // return result array (autorleased) !
   return array; 
}

The above can be enhanced to support more data types etc., but it can serve as a decent start.

cPlayer

MPMediaPlayerController is the class that manages audio files playback. In order to play an audio media (or a playlist), three stages are involved:

  1. Instantiate a MPMediaPlayerController object.
  2. Perform a media query to get an array of MPMediaItem(s).
  3. Set the MPMediaPlayerController queue with the array obtained in step 2.

Once these steps are followed, media commands can be issued (play, pause, next ...). There is one more optional step: subscribe to notification messages so we can be alerted when certain events take place - play state has changed, play item has changed, and external volume control has changed.

As stated above, the order in which a queue's media items is played is unpredictable (to me, it seams so) - especially when the media items are MP3 files, so in the abPlayer application, I play one media item at a time and manage the transition from one item to the next, as well as skip commands (prev, next) manually.

For each book, the application maintains an array of dictionary items, each item holding attributes that identify the media item. These attributes are: the media title, its artist, and its album - it is presumed that these three attributes identify a unique media item.

The code below demonstrate the steps discussed above:

Step 1 (and the optional step)
C++
//... somewhere in the init stage

// subscribe to media player notification messages
[notificationCenter addObserver:self 
           selector:@selector(handleExternalVolumeChanged:)
           name:MPMusicPlayerControllerVolumeDidChangeNotification 
           object:nil];
[notificationCenter addObserver:self 
           selector:@selector(handlePlayItemChanged:)
           name:MPMusicPlayerControllerNowPlayingItemDidChangeNotification 
           object:nil];
// instantiate the audio player
audioPlayer = [[MPMusicPlayerController iPodMusicPlayer] retain]; 
// ensure repeat mode is off
[audioPlayer setRepeatMode:MPMusicRepeatModeNone];
// start receiving notification messages
[audioPlayer beginGeneratingPlaybackNotifications];
  • Get the MPMediaItem
  • Create three predicates with the three identifying attributes
  • Create an NSSet with these predicates
  • Perform a MPMediaQuery
Step 2
C++
// locate media item and build and instantiate an MPMediaItem
-(NSArray*)mediaItemsArray:(int)index
{
   // get dictionary with media item attributes
   NSDictionary* dic = [nowPlayingItems objectAtIndex:index];
   // create 3 predicates: Album, Title, Artist
   MPMediaPropertyPredicate* albumP = [MPMediaPropertyPredicate 
               predicateWithValue:[dic objectForKey:@"ALBUM"]
               forProperty: MPMediaItemPropertyAlbumTitle]; 
   MPMediaPropertyPredicate* titleP = [MPMediaPropertyPredicate 
               predicateWithValue:[dic objectForKey:@"TITLE"]
               forProperty: MPMediaItemPropertyTitle]; 
   MPMediaPropertyPredicate* artistP = [MPMediaPropertyPredicate 
               predicateWithValue:[dic objectForKey:@"ARTIST"]
               forProperty: MPMediaItemPropertyArtist]; 
   // create s set of the 3 predicates
   NSSet* set = [NSSet setWithObjects:albumP, artistP, titleP, nil];
   // generate a query
   MPMediaQuery* query = [[[MPMediaQuery alloc] 
		initWithFilterPredicates:set] autorelease];
   // return the media item found in the query (there should normally be only one)
   return [query items];
}

Load the playlist (array of MPMediaitems - in our case, one item should be found in the query).

Step 3
C++
// load a media item and make it playable (play if requsted)
-(void)prepareToPlay:(float)location play:(BOOL)play
{
   // ensure player is paused
   [audioPlayer pause];
   // get dictionary describing item to play
   NSDictionary* dic = [nowPlayingItems objectAtIndex:nowPlayingIndex];
   // get an MPMediaItem
   NSArray* array = [self mediaItemsArray:nowPlayingIndex];
   // if array is not empty - load into player
   if([array count])
      [audioPlayer setQueueWithItemCollection:[MPMediaItemCollection
                       collectionWithItems:array]];
   else  // empty list - media item no longer exists
   {
       // show error message and quit
       UIAlertView* alert = [[UIAlertView alloc] initWithTitle:@"Error - zero count query"
                      message:[NSString stringWithFormat:@"TITLE:%@, ALBUM:%@, ARTIST:%@",
                      [dic objectForKey:@"TITLE"],
                      [dic objectForKey:@"ALBUM"], 
                      [dic objectForKey:@"ARTIST"]] 
                      delegate:nil cancelButtonTitle:@"Ok" otherButtonTitles:nil];
                     [alert show];
      [alert release];
      return;
   }
   // set playing location
   // location 0.0 causes unpredictable behaviour
   [audioPlayer setCurrentPlaybackTime:((location)? location: 0.3)];
   // set volume 
   [audioPlayer setVolume:volume / 100];
   // play
   if(play)
      [audioPlayer play];
}

That's all folks.

Now the audio player is loaded, ready (and playing). We can traverse the queue (if there is more than one item in the queue), pause, play again ...

C++
//... now audioPlayer can respond to commands ...
[audioPlayer next];

History

The current version is 1.32. This is actually the first version released. The changes from 1.00 to 1.32 represent small enhancements and bug fixes discovered by me since I started actually using the application, and suggestion/bugs discovered by a small group of friendly users.

Once this app is used by more people, I will finalize the 2.00 version with whatever is found or suggested that makes sense to me.

A full change log has been included in the source zip download.

Revisions

I have made some changes to the application. Mostly bug fixes and some suggested enhancements.

The download now is version 2.00. Following is the list of changes:

  • (Player) Added warning message when trying to create a media queue with more than 1 file.
  • (Library form) Added support for manual reordering of media files.
  • (Browser form) Added 'Composers' and 'Genre' to the initial list of collections/predicates.
  • (Library form) Fix: Corrected section title of table view in edit and delete modes.
  • (Library form) Fix: Stay in edit mode after edit action performed.
  • (Library form) Replaced 'del' button functionality with cell swipe. Removed button.
  • (Edit form) On entry, make edit control the first responder so it's focused and keyboard is showing.
  • (Edit form) Rev: Removed erasing of Back button so Cancel can be achieved by rolling back.
  • (All over) Prevents screens from tilting when device is (some screens don't look good in landscape orientation).
  • (Library form) In auto-selection routine, also select active shelf.
  • (Browser form) Added images to all table view cells.
  • (Browser from) Rev: Used different images to indicate selection.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)