HTML5 Powered with Multimedia, and Offline & Storage(or Developing a Cross-Platform HTML5 Offline App – Part 2)

In developing audio thai I had the need to include MP3 files in the offline cache.  The problem I ran into is that some browsers simply refuse to cache MP3 files, even if they’re explicitly defined in the cache.manifest.  I really have no idea why this is the case, and I hope it gets fixed.  But I needed a solution.

So I decided to base64 encode the files and serve them up using a data URI.  Essentially I created a PHP admin script which would read the MP3 file and write a JS file containing a single variable, with the name of the original MP3.  I probably should have created a single array and just added the values to it rather than creating 10,000 global variables… but the names were unique and since it’s a self-contained app there’s not as much reason to be concerned about cluttering the global namespace.  The code to do the conversion was fairly straightforward.

<?php
function base64_mp3( $infile, $outfile ) {
	$contents = file_get_contents( $infile );
	$base64 = base64_encode( $contents );
	$audio = 'data:audio/mpeg;base64,' . $base64;
	$js = 'var ' . $filename . '="' . $audio . '";';
	$file = '../app/audio/' . $outfile . '.jgz';
	if ( file_exists( $file ) ) {
		unlink( $file );
	}
	$f = fopen( $file, 'w' );
	fwrite( $f, gzencode( $js ) );
	fclose( $f );
}
?>

In here I also gzip the output in order to reduce size / download time.  And, because Safari requires a .jgz extension for gzipped JS files, that’s what I had to use.  So I also had to modify my .htaccess in order to tell other browsers what to do with a .jgz file.

.htaccess

AddType "text/cache-manifest" .manifest
AddType "text/javascript;charset=UTF-8" .jgz
AddEncoding gzip .jgz

Since I didn’t want to include every JS audio file and load it all into memory at runtime I dynamically loaded the required JS file in my playback function and then decoded the variable.

function playAudioFile(audio_name) {
	// Create script element and load audio js file
	var fileref = document.createElement('script');
	fileref.setAttribute('type', 'text/javascript');
	fileref.setAttribute('src', 'audio/' + audio_name + '.jgz');
	if(typeof(fileref) != 'undefined') {
		// Place script tag at top of <head>, otherwise the variable defined there is not available
		$('head').prepend(fileref);
	}

	// Eval audio js var
	var audio = eval(audio_name);

	document.getElementById('emb_audio').innerHTML = '<audio id="html5_audio" src="' + audio + '"></audio>';
	var html5audio = document.getElementById('html5_audio');
	document.getElementById('html5_audio').play();
}

So, that issue resolved, it was time to tackle the next one – Firefox.  Firefox doesn’t support MP3 playback via the HTML5 <audio> tag.  Originally I was using a traditional <object> for audio playback, but Firefox ran into an issue here too.  It seems that there’s a limit to the size of a Data URI that Firefox can pass to <object> as a data attribute.  At least that’s what I believe the issue was.  It seemed that any base64 encoded string I passed that was longer than about 30k just wouldn’t play in Firefox with the <object> tag.  And since Firefox didn’t support HTML5 <audio> + MP3, I had only one option remaining… OGG.

First I had to do the MP3 → OGG conversion.  After finding a few tools that sucked at this for various reasons – mostly due to memory leaks or too many open file handles when batch processing 10,000 MP3s – I came across Switch.  It was fantastic (and free!).  Queued up all the MP3s and let ‘er go.

Conversion complete, now I had to figure out a way to get Firefox to cache the OGG files while Chrome, Safari and IE cached the MP3 files.  For this I created another admin script in PHP which generated two separate cache manifest files named mp3.cache.manifest and ogg.cache.manifest respectively.

<?php
set_time_limit( 0 );

$root = '../app/';

function write( $s ) {
	global $root, $files;

	if ( strpos( $s, '/mp3/' ) !== false ) {
		// mp3
		file_put_contents( $root . 'mp3.cache.manifest', $s . "n", FILE_APPEND );
	}
	elseif ( strpos( $s, '/ogg/' ) !== false ) {
		// ogg
		file_put_contents( $root . 'ogg.cache.manifest', $s ."n", FILE_APPEND );
	}
	else {
		// both
		file_put_contents( $root . 'mp3.cache.manifest', $s . "n", FILE_APPEND );
		file_put_contents( $root . 'ogg.cache.manifest', $s . "n", FILE_APPEND );
	}

	echo $s . "n";
}

$manifest = $root . 'cache.manifest';
if ( file_exists( $manifest ) ) {
	unlink( $manifest );
}
$manifest = $root . 'mp3.cache.manifest';
if ( file_exists( $manifest ) ) {
	unlink( $manifest );
}
$manifest = $root . 'ogg.cache.manifest';
if ( file_exists( $manifest ) ) {
	unlink( $manifest );
}
echo '<pre>' . "\n";

// Write the first line
write( "CACHE MANIFEST" );

// Initialize the $hashes string
$hashes = "";

$dir = new RecursiveDirectoryIterator($root);

$files = 0;

// Iterate through all the files/folders in the current directory
foreach ( new RecursiveIteratorIterator( $dir ) as $file ) {
	$info = pathinfo( $file );

	// If the object is a file
	// and it's not a dotfile
	// and it's not manifest-related
	// add it to the list
	if ( $file->IsFile() 
		&& substr( $file->getFilename(), 0, 1 ) != "."
		&& $file != $root . "manifest.php"
		&& $file != $root . "cache.manifest"
		&& $file != $root . "mp3.cache.manifest"
		&& $file != $root . "ogg.cache.manifest"
		) {
		$write_file = str_replace( $root, './', $file );
		// Replace spaces with %20 or it will break
		write( str_replace( ' ', '%20', $write_file ) );

		// Add this file's hash to the $hashes string
		$hashes .= md5_file( $file );

		$files++;
	}
}

// Hash the $hashes string and output
write( "# Hash: " . md5( $hashes ) );
write( "# Files: " . $files );

echo '</pre>';
?>

Then I pointed the manifest of my app to a file called manifest.php which determined, based on browser, which manifest to serve up.  I realize that the proper approach would have been to use JS to determine which filetype was supported by the browser and send that information to manifest.php so that it could accurately serve up the right manifest, but this app was only targeting the big 4, 3 of which support MP3 with Firefox being the black sheep.

For this I used the Browser PHP class from Chris Schuld.

<?php
include( '../inc/Browser.php' );
$browser = new Browser();
$type = '';
if ( $browser->isBrowser( Browser::BROWSER_CHROME )
	|| $browser->isBrowser( Browser::BROWSER_SAFARI )
	|| $browser->isBrowser( Browser::BROWSER_IE )
	) {
	$type = 'mp3';
}
else {
	// Firefox, Opera
	$type = 'ogg';
}

// Add the correct Content-Type for the cache manifest
header( 'Content-Type: text/cache-manifest' );
readfile( $type . '.cache.manifest' );
?>

And finally, I had to modify my playAudioFile() function to account for the different types / paths.

function playAudioFile(audio_name) {
	var html5audio = document.getElementById('html5_audio');
	var canPlayMp3 = false, canPlayOgg = false;
	if(html5audio.canPlayType) {
		canPlayMp3 = !!html5audio.canPlayType && "" != html5audio.canPlayType('audio/mpeg');
		canPlayOgg = !!html5audio.canPlayType && "" != html5audio.canPlayType('audio/ogg');
	}
	var type = '';
	if(canPlayMp3) {
		type = 'mp3';
	}
	else {
		type = 'ogg';
	}

	// Create script element and load audio js file
	var fileref = document.createElement('script');
	fileref.setAttribute('type', 'text/javascript');
	fileref.setAttribute('src', 'audio/' + type + '/' + audio_name + '.jgz');
	if(typeof(fileref) != 'undefined') {
		// Place script tag at top of <head>, otherwise the variable defined there is not available
		$('head').prepend(fileref);
	}

	// Eval audio js var
	var audio = eval(audio_name);

	document.getElementById('emb_audio').innerHTML = '<audio id="html5_audio" src="' + audio + '"></audio>';
	html5audio = document.getElementById('html5_audio');
	document.getElementById('html5_audio').play();
}

And that’s it!  Ironically, I’ve now switched this app over to Titanium, so this entire process is moot since I can now just bundle the native MP3s with the app.  But hopefully it helps someone else out there.  Overall I think it’s a pretty slick solution if you need to include audio in your offline app.