In my home setup I have all my audio files stored on a home server. The primary format is OGG but a few files are in MP3 format. I'm mainly interested in streaming the OGG files.
I'm still a "traditional" listener in that I like to listen to all the songs on a CD and so the files are organised by directories each containing all the audio files on a CD. I've set up Apache directory listings so that all the audio files show up but there is also a "Play all" button which when clicked on calls a CGI script which delivers an M3U playlist of all the audio files in that directory.
This arrangement works fine with most browsers. I can list the directories of CDs, and within each directory can either play single tracks or all tracks by clicking on the individual track or on the "Play all" button.
For listening through my hifi system, I have been using a thin client - an HP T5735 - running a home built version of the Linux distro XPud. This client has a DVI port for connection to the TV and an ordinary analogue audio port for sound. Using Firefox on the HP T5735 I can successfully stream audio (and videos too) over wireless to my home theatre system.
I came across the Kogan Agora Internet TV Portal. This is a simple Android 2.2 box running almost as a thin client - diskless, with HDMI output. I could use my Adesso remote wireless keyboard and mouse to control it via its USB ports. This sounded like a fun upgrade to the HP so I bought one.
The Android box will play single OGG files through the browser with no problems. But it won't play M3U playlists. The Kogan team told me to try the app "Just Playlists" but this dies when the browser tries to give it a playlist. So what follows is my investigations into just what works for Android playing streaming audio across a network. In particular, I want to be able to access M3U playlists on my server and stream OGG files from it.
I've used the Android emulator running on Linux (Ubuntu 11.10) for all tests. I've also got Android 2.2 on the Kogan Android TV and Android 3.2.1 on a ASUS Transformer to confirm some of the results.
Android gives you a browser in each emulator.
Android version | URL | HTML 5 audio | M3U URL | ||
---|---|---|---|---|---|
OGG | MP3 | OGG | MP3 | ||
4.0.3 | OK | No | OK | No | No |
3.2 | OK | No | OK | No | No |
2.2 | No | No | No | No | No |
The browsers won't give me playlist support so I have to look elsewhere. I tried some apps but without success. So, about time to try some programming - after all, I was teaching J2ME programming over 5 years ago!
If you are writing Java programs to play media files, then the first port of call is the MediaPlayer class. Typical code to play a file using the streaming mechanism of MediaPlayer is
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
try {
Uri uri = Uri.parse("http://192.168.1.9/music/test.ogg");
MediaPlayer player = new MediaPlayer();
player.setAudioStreamType(AudioManager.STREAM_MUSIC);
player.setDataSource(this, uri);
player.prepare();
player.start();
} catch(Exception e) {
System.out.println(e.toString());
}
}
Here are some results
Android version | OGG | MP3 |
---|---|---|
4.0.3 | Ok | No "java.io.IOException: Prepare failed.: status=0x1" |
3.2 | Ok | No "Couldn't open file on client side, trying server side Error 1 |
2.2 | No "Couldn't open file on client side, trying server side" "media server died" "java.io.IOException: Prepare failed.: status=0x64" Error 100 |
No "Couldn't open file on client side, trying server side Error 1 |
The conclusion is gloomily obvious: MediaPlayer is not the way to go when streaming audio across the network if you might need to use Android 2.2 - which I do. Or the patented MP3 format which you should avoid.
I want my player to be called by the browser when it encounters an M3U file since it can't handle the playlist files. This is standard Android: register the app to manage intents from the browser. the MIME type for M3U files is audio/x-mpegurl so the intent is set to handle VIEW requests with that MIME type. I also want to be able to call it as an application so I leave the MAIN intent as well.
In AndroidManifest.html I put
<activity
android:name=".AudioPlayerActivity"
android:label="@string/app_name" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="audio/x-mpegurl" android:scheme="http"/>
</intent-filter>
</activity>
and in my onCreate() I put
Intent intent = getIntent();
if (intent.getAction().equals(Intent.ACTION_MAIN)) {
// main just for testing
new DownloadM3U().execute("http://192.168.1.9/music/test.m3u");
} else if (intent != null && intent.getData() != null) {
CharSequence text = intent.getData().toString();
new DownloadM3U().execute(intent.getData().toString());
}
This works fine: the browser calls my app directly or gives me a choice if more than one app can handle music files (some music apps register their MIME type as audio/* so match playlists even if they can't handle them).
Since MediaPlayer in Android 2.2 doesn't handle streaming OGG files, I experimented with some players that can handle the OGG files (but not the M3U files). So I looked at calling other players from my app.
This is standard Android too: set up an Intent and call startActivityForResult(). By this time I'm down in some class and startActivityForResult() needs to be called on my app's Activity. I used a kludge: Set a public static field of my app to be the app itself so I can access it from anywhere. I will fix that later - for now just hope that I don't have two instances running, with the second overriding the static value.
my code looks like
Intent intent = new Intent();
intent.setAction(android.content.Intent.ACTION_VIEW);
intent.setDataAndType(Uri.parse(uriStr), "audio/*");
AudioPlayerActivity.activity.listPlayer = this;
AudioPlayerActivity.activity.startActivityForResult(intent, 23);
I need to know when the called app finishes. That's why I asked for the result. So in the activity I have
protected void onActivityResult (int requestCode, int resultCode, Intent data) {
System.out.println("External Player finished");
synchronized(listPlayer) {
listPlayer.notifyAll();
}
}
But there is a big problem with this: it requires the app playing a single audio file to finish, and they don't! For example, the standard Music app just sits there asking if you want to replay the file. So I can't tell when the app has finished playing.
A dead end.
I found the source code for the Music player on the android-386 site (the standard Android download site has broken instructions). In files such as MediaPlabackActivity there is cool stuff like
case TRACK_ENDED:
...
notifyChange(PLAYBACK_COMPLETE);
...
where
private void notifyChange(String what) {
Intent i = new Intent(what);
...
sendBroadcast(i);
...
}
So I looked for broadcast events:
BroadcastReceiver receiver = new AudioBroadcastReceiver();
IntentFilter receiveFilter = new IntentFilter("com.android.music.playstatechanged");
this.registerReceiver(receiver, receiveFilter);
But I couldn't get it to work with the music players I was using. I didn't persist, because there doesn't seem to be any specification for music players so there is no guarantee that any particular player will broadcast messages when tracks finish.
There is a native code audio player which uses JNI and cann be called from Android. It is Open SL ES for Android . Unfortunately it only works with Android 2.3 and above. Another dead end.
Finally, I settled on the following to play single OGG files: create and use a MediaPlayer if possible. If not, download the file to local storage and then play it. This works with Android 2.2 (download files) up to Android 4.3 (MediaPlayer streams OGG okay).
Success!
I only need three classes for this app: the main activity with the GUI, downloading the M3U playlist and playing the songs in the list.
The activity class is
package jan.newmarch;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
public class AudioPlayerActivity extends Activity {
public static Context context;
public static AudioPlayerActivity activity;
public ListPlayer listPlayer;
public LinearLayout root;
private TextView artistInfo;
private TextView trackInfo;
private ImageView image;
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
context = this;
activity = this;
createView();
setContentView(root);
Intent intent = getIntent();
// Are we called from main or by our M3U intent?
if (intent.getAction().equals(Intent.ACTION_MAIN)) {
System.out.println("Is main");
new DownloadM3U(this).execute("http://192.168.1.9/music/test.m3u");
} else
if (intent != null && intent.getData() != null) {
new DownloadM3U(this).execute(intent.getData().toString());
}
}
@Override
public void onPause() {
listPlayer.stopPlaying();
super.onPause();
}
private void createView() {
root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
LinearLayout buttonBox = new LinearLayout(this);
artistInfo = new TextView(this);
artistInfo.setText("");
trackInfo = new TextView(this);
trackInfo.setText("");
image = new ImageView(this);
buttonBox.setOrientation(LinearLayout.HORIZONTAL);
Button stopBtn = new Button(this);
stopBtn.setText("Stop");
Button FFBtn = new Button(this);
FFBtn.setText("Next");
root.addView(image);
root.addView(artistInfo);
root.addView(trackInfo);
root.addView(buttonBox);
buttonBox.addView(FFBtn);
buttonBox.addView(stopBtn);
stopBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
listPlayer.stopPlaying();
}
});
FFBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
listPlayer.stopCurrentTrack();
}
});
}
protected void onActivityResult (int requestCode, int resultCode, Intent data) {
System.out.println("External Player finished");
synchronized(listPlayer) {
listPlayer.notifyAll();
}
}
public void setTrackInfo(String artist, String track, Bitmap image) {
artistInfo.setText(artist);
trackInfo.setText(track);
this.image.setImageBitmap(image);
}
}
The M3U downloader is
package jan.newmarch;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
import java.util.Vector;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
import android.net.Uri;
import android.os.AsyncTask;
public class DownloadM3U extends AsyncTask<String, Void, String[]> {
private AudioPlayerActivity activity;
public DownloadM3U(AudioPlayerActivity activity) {
this.activity = activity;
}
protected String[] doInBackground(String... urls) {
BufferedReader in = null;
Vector<String> lines = new Vector<String>();
try {
HttpClient client = new DefaultHttpClient();
HttpGet request = new HttpGet();
request.setURI(new URI(urls[0]));
HttpResponse response = client.execute(request);
in = new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
String line = "";
while ((line = in.readLine()) != null) {
line = line.trim();
// ignore blank lines and comments
if (line.equals("")) continue;
if (line.charAt(0) == '#') continue;
line = Uri.decode(line);
lines.add(line);
}
in.close();
} catch(Exception e) {
return null;
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return ((String[]) lines.toArray(new String[0]));
}
protected void onPostExecute(String[] lines) {
(new Thread(new ListPlayer(activity, lines))).start();
}
}
The list player is
package jan.newmarch;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.net.URL;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.net.Uri;
import android.widget.TextView;
public class ListPlayer implements Runnable {
private String [] uris;
private TextView tv;
private AudioPlayerActivity activity;
private MediaPlayer player;
private boolean keepPlaying = true;
public ListPlayer(AudioPlayerActivity activity, String[] uris) {
this.uris = uris;
this.activity = activity;
activity.listPlayer = this;
}
public void run() {
for (String uri:uris) {
if (!keepPlaying) {
break;
}
System.out.println("About to play " + uri);
play1(uri);
synchronized(this) {
try {
this.wait();
} catch (Exception e) {
System.out.println("play failed " + e.toString());
}
}
}
}
private void play1(String uriStr) {
setTrackInfo(uriStr);
try {
// Try the URL directly (ok for Android 3.0 upwards)
tryMediaPlayer(uriStr);
} catch(Exception e) {
// Try downloading the file and then playing it - needed for Android 2.2
try {
downloadToLocalFile(uriStr, "audiofile.ogg");
File localFile = activity.getFileStreamPath("audiofile.ogg");
tryMediaPlayer(localFile.getAbsolutePath());
} catch(Exception e2) {
System.out.println("File error " + e2.toString());
}
}
}
private void downloadToLocalFile(String uriStr, String filename) throws Exception {
URL url = new URL(Uri.encode(uriStr, ":/"));
BufferedInputStream reader =
new BufferedInputStream(url.openStream());
File f = new File("audiofile.ogg");
FileOutputStream fOut = activity.openFileOutput("audiofile.ogg",
Context.MODE_WORLD_READABLE);
BufferedOutputStream writer = new BufferedOutputStream(fOut);
byte[] buff = new byte[1024];
int nread;
System.out.println("Downloading");
while ((nread = reader.read(buff, 0, 1024)) != -1) {
writer.write(buff, 0, nread);
}
writer.close();
}
private void tryMediaPlayer(String uriStr) throws Exception {
player = new MediaPlayer();
player.setOnCompletionListener(new OnCompletionListener() {
public void onCompletion(MediaPlayer player) {
synchronized(ListPlayer.this) {
ListPlayer.this.notifyAll();
}
}
});
player.setAudioStreamType(AudioManager.STREAM_MUSIC);
player.setDataSource(uriStr);
player.prepare();
player.start();
}
public void stopCurrentTrack() {
player.stop();
synchronized(this) {
notifyAll();
}
}
public void stopPlaying() {
keepPlaying = false;
player.stop();
synchronized(this) {
notifyAll();
}
}
public void setTrackInfo(String uri) {
String[] parts = uri.split("/");
String trackNameExt = parts[parts.length-1];
final String trackName = trackNameExt.split("[.]")[0];
final String artist = parts[parts.length-2];
System.out.println("Track info " + trackName + " plus " + artist);
// download cover if possible - I keep them as cover.jpg in the CD's directory
// Rebuild path with last part changed
parts[parts.length-1] = "cover.jpg";
StringBuffer result = new StringBuffer();
if (parts.length > 0) {
result.append(parts[0]);
for (int i = 1; i < parts.length; i++) {
result.append("/");
result.append(parts[i]);
}
}
// Download url and either get a valid image or null
ByteArrayOutputStream imageStream = new ByteArrayOutputStream();
int count = 0;
try {
URL url = new URL(Uri.encode(result.toString(), ":/"));
System.out.println("Opening stream " + url.toString());
BufferedInputStream reader = new BufferedInputStream(url.openStream());
byte[] buff = new byte[1024];
int nread;
while ((nread = reader.read(buff, 0, 1024)) != -1) {
imageStream.write(buff, 0, nread);
count += nread;
}
} catch(Exception e) {
System.out.println("Image failed " + e.toString());
}
// try to decode - get valid image or nulll
final Bitmap image = BitmapFactory.decodeByteArray(imageStream.toByteArray(), 0, count);
// Set info in GUI - run in GUI thread
new Thread(new Runnable() {
public void run() {
activity.root.post(new Runnable() {
public void run() {
activity.setTrackInfo(artist, trackName, image);
}
});
}
}).start();
}
}
And finally, here is the manifest:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="jan.newmarch"
android:versionCode="1"
android:versionName="1.0" >
<uses-sdk android:minSdkVersion="8" />
<uses-feature />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:icon="@drawable/ic_launcher"
android:label="@string/app_name" >
<activity
android:name=".AudioPlayerActivity"
android:label="@string/app_name" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="audio/x-mpegurl" android:scheme="http"/>
</intent-filter>
</activity>
</application>
</manifest>