Audiobook Player for iPhone






4.73/5 (15 votes)
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:
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:
When the 'Properties' button is pressed, the following screen is displayed:
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:
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:
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:
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:
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 wrappercPlayer
- 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:
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:
-(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.
-(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.
-(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:
-(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:
-(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:
- Instantiate a
MPMediaPlayerController
object. - Perform a media query to get an array of
MPMediaItem
(s). - 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:
//... 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
// 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 MPMediaitem
s - in our case, one item should be found in the query).
// 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 ...
//... 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.