Permalink
Browse files

Added enqueuing of tracks as and when appropriate.

Added a bit to the design doc.
Added a few TODO items.
  • Loading branch information...
jjc1138
jjc1138 committed Nov 17, 2008
1 parent 605d8c5 commit edb307fd54c5c3c7bbd31cfb88587b53eab13c7e
Showing with 229 additions and 14 deletions.
  1. +12 −1 Design.txt
  2. +4 −0 TODO.txt
  3. +213 −13 src/net/jjc1138/android/scrobbler/ScrobblerService.java
View
@@ -1,4 +1,4 @@
-We have defined a simple type of broadcast that music players can use to update other apps about their current status (net.jjc1138.android.musicplayerstatus). It should be sent at least when playback starts, when the current track changes, and when the player is paused so that nothing is currently playing. Sending it more often doesn't do any harm. Every broadcast must contain the "playing" extra, which is a boolean saying whether music is currently playing or not. Every broadcast where "playing" is true must also have details about the current track that is playing. This can either be "id", which is an int which indicates the track's ID in the MediaStore, or the actual metadata for the track as Strings ("track", "artist", "album", etc.).
+We have defined a simple type of broadcast that music players can use to update other apps about their current status (net.jjc1138.android.musicplayerstatus). It should be sent at least when playback starts, when the current track changes, and when the player is paused so that nothing is currently playing. Sending it more often doesn't do any harm. Every broadcast must contain the "playing" extra, which is a boolean saying whether music is currently playing or not. Every broadcast where "playing" is true must also have details about the current track that is playing. This can either be "id", which is an int which indicates the track's ID in the "external" (memory card) MediaStore, or the actual metadata for the track as Strings ("track", "artist", "album", etc.).
This type of broadcast is received by StatusBroadcastReceiver and immediately passed on to the ScrobblerService (which is started if it isn't running already). The ScrobblerService decides when to enqueue a track for scrobbling, and also performs the actual scrobbling itself.
@@ -20,3 +20,14 @@ When the Activity is hidden it stores the current uncommitted preferences if the
When the ScrobblerService is told that it will be shutdown (how?), it saves the current scrobbling queue to a file.
When the Activity starts (or resumes) it makes a connection to the ScrobblerService. When the ScrobblerService gets the connection it immediately updates the Activity about what is going on right now (how many tracks in the queue/are we scrobbling/was the last scrobble successful).
+
+
+
+There are three states the ScrobblerService can be in:
+ 0) Nothing is playing. This is the starting state.
+ 1) A track is currently playing. It started at time t.
+ 2) A track is paused. It started at time t, and has played for a total of s seconds so far.
+
+The types of new information we can get from broadcasts are:
+ A) A track is playing.
+ B) Playback is stopped/paused.
View
@@ -3,3 +3,7 @@ Add copyright/license notices where necessary.
Make an icon.
Static helper methods that simplify sending broadcasts for other players.
Can we use permissions to stop people starting our services (for the sake of minimizing the public interface)?
+Love/ban/skip.
+Add information to the UI about when scrobbling will take place.
+Add UI strings and interface constants for the BANNED and BADTIME handshake responses.
+Add notifications for important handshake failures that require user action (BANNED, BADAUTH, BADTIME).
@@ -1,40 +1,140 @@
package net.jjc1138.android.scrobbler;
-import java.util.Queue;
+import java.util.NoSuchElementException;
+import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import android.app.Service;
+import android.content.ContentUris;
+import android.content.Context;
import android.content.Intent;
+import android.database.Cursor;
import android.os.IBinder;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
+import android.provider.MediaStore;
import android.util.Log;
+class IncompleteMetadataException extends Exception {
+ private static final long serialVersionUID = 1L;
+}
+
+class Track {
+ public Track(Intent i, Context c) throws IncompleteMetadataException {
+ id = i.getIntExtra("id", -1);
+
+ if (id != -1) {
+ // TODO Only fetch the columns we use.
+ Cursor cur = c.getContentResolver().query(
+ ContentUris.withAppendedId(
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id),
+ null, null, null, null);
+ try {
+ if (cur == null) {
+ throw new NoSuchElementException();
+ }
+
+ cur.moveToFirst();
+ length = cur.getLong(cur.getColumnIndex(
+ MediaStore.Audio.AudioColumns.DURATION));
+ } finally {
+ cur.close();
+ }
+ } else {
+ // TODO Add support for fully specified metadata. Throw if it is
+ // incomplete.
+ throw new IncompleteMetadataException();
+ }
+ }
+
+ public long getLength() {
+ return length;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Track)) {
+ return false;
+ }
+ Track other = (Track) o;
+ if (id != -1) {
+ return id == other.id;
+ }
+ // TODO Check other metadata fields.
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return id;
+ }
+
+ private int id;
+ private long length;
+}
+
+class QueueEntry {
+ public QueueEntry(Track track, long startTime) {
+ this.track = track;
+ this.startTime = startTime;
+ }
+
+ public Track getTrack() {
+ return track;
+ }
+
+ public long getStartTime() {
+ return startTime;
+ }
+
+ private Track track;
+ private long startTime;
+}
+
public class ScrobblerService extends Service {
static final String LOG_TAG = "ScrobbleDroid";
static final int SUCCESSFUL = 0;
static final int NOT_YET_ATTEMPTED = 1;
static final int FAILED_AUTH = 2;
static final int FAILED_NET = 3;
static final int FAILED_OTHER = 4;
-
+
final RemoteCallbackList<IScrobblerServiceNotificationHandler>
notificationHandlers =
new RemoteCallbackList<IScrobblerServiceNotificationHandler>();
-
+
private int lastScrobbleResult = NOT_YET_ATTEMPTED;
- private Queue<Object> queue = new LinkedBlockingQueue<Object>();
-
+ private BlockingQueue<QueueEntry> queue =
+ new LinkedBlockingQueue<QueueEntry>();
+
+ private QueueEntry lastPlaying = null;
+ private boolean nowPaused = false;
+ private long lastPlayingTimePlayed = 0;
+ private long lastResumedTime = -1;
+
+ synchronized void updateAllClients() {
+ final int N = notificationHandlers.beginBroadcast();
+ for (int i = 0; i < N; ++i) {
+ updateClient(notificationHandlers.getBroadcastItem(i));
+ }
+ notificationHandlers.finishBroadcast();
+ }
+
+ synchronized void updateClient(IScrobblerServiceNotificationHandler h) {
+ try {
+ // TODO Does this have to be called from the main event thread?
+ h.stateChanged(queue.size(), false, lastScrobbleResult);
+ } catch (RemoteException e) {}
+ }
+
private final IScrobblerService.Stub binder = new IScrobblerService.Stub() {
@Override
public void registerNotificationHandler(
IScrobblerServiceNotificationHandler h) throws RemoteException {
notificationHandlers.register(h);
- synchronized (ScrobblerService.this) {
- h.stateChanged(queue.size(), false, lastScrobbleResult);
- }
+ updateClient(h);
}
@Override
@@ -53,22 +153,122 @@ public void prefsUpdated() throws RemoteException {
public void startScrobble() throws RemoteException {
// TODO Auto-generated method stub
}
-
+
};
@Override
public IBinder onBind(Intent intent) {
return binder;
}
+ private void newTrackStarted(Track t, long now) {
+ this.lastPlaying = new QueueEntry(t, now);
+ nowPaused = false;
+ lastPlayingTimePlayed = 0;
+ lastResumedTime = now;
+ Log.v(LOG_TAG, "New track started.");
+ }
+
+ private boolean playTimeEnoughForScrobble() {
+ final long playTime = lastPlayingTimePlayed;
+ return playTime >= 30000 && ((playTime >= 240000) ||
+ (playTime >= lastPlaying.getTrack().getLength() / 2));
+ }
+
+ private void handleIntent(Intent intent) {
+ Log.v(LOG_TAG, "Status: " +
+ ((intent.getBooleanExtra("playing", false) ? "playing" : "stopped")
+ + " track " + intent.getIntExtra("id", -1)));
+
+ if (!intent.hasExtra("playing")) {
+ // That one is mandatory.
+ return;
+ }
+ Track t;
+ try {
+ t = new Track(intent, this);
+ } catch (IncompleteMetadataException e) {
+ return;
+ } catch (NoSuchElementException e) {
+ return;
+ }
+ long now = System.currentTimeMillis();
+
+ if (intent.getBooleanExtra("playing", false)) {
+ if (lastPlaying == null) {
+ newTrackStarted(t, now);
+ } else {
+ if (nowPaused) {
+ // lastPlaying track was paused.
+ if (lastPlaying.getTrack().equals(t)) {
+ lastResumedTime = now;
+ nowPaused = false;
+ Log.v(LOG_TAG, "Previously paused track resumed.");
+ } else {
+ if (playTimeEnoughForScrobble()) {
+ queue.add(lastPlaying);
+ updatedQueue();
+ Log.v(LOG_TAG, "Enqueued previously paused track.");
+ } else {
+ Log.v(LOG_TAG, "Previously paused track wasn't " +
+ "playing long enough to scrobble.");
+ }
+ newTrackStarted(t, now);
+ }
+ } else {
+ if (lastPlaying.getTrack().equals(t)) {
+ // lastPlaying track is still playing: NOOP.
+ } else {
+ // Change of track. Check if we can scrobble the old
+ // one.
+ lastPlayingTimePlayed += now - lastResumedTime;
+ if (playTimeEnoughForScrobble()) {
+ queue.add(lastPlaying);
+ updatedQueue();
+ Log.v(LOG_TAG,
+ "Enqueued previously playing track.");
+ } else {
+ Log.v(LOG_TAG, "Previously playing track wasn't " +
+ "playing long enough to scrobble.");
+ }
+ newTrackStarted(t, now);
+ }
+ }
+ }
+ } else {
+ // Paused/stopped.
+ if (lastPlaying == null || nowPaused) {
+ // We weren't playing before and we aren't playing now: NOOP.
+ } else {
+ // A track is currently playing.
+ lastPlayingTimePlayed += now - lastResumedTime;
+ nowPaused = true;
+ Log.v(LOG_TAG, "Track paused. Total play time so far is " +
+ lastPlayingTimePlayed + ".");
+ }
+ }
+
+ // TODO Make sure we're sensibly handling (maliciously) malformed
+ // Intents.
+ }
+
@Override
public void onStart(Intent intent, int startId) {
super.onStart(intent, startId);
- // TODO Don't forget to handle (maliciously) malformed Intents.
- Log.v(LOG_TAG,
- ((intent.getBooleanExtra("playing", false) ? "playing" : "stopped")
- + " track " + intent.getIntExtra("id", -1)));
+ handleIntent(intent);
+ stopIfIdle();
+ }
+
+ private void stopIfIdle() {
+ // TODO stopSelf() if not playing, queue empty and not scrobbling. Save
+ // the lastPlaying information (including lastPlayingTimePlayed) before
+ // stopping.
+ }
+
+ private void updatedQueue() {
+ // TODO Launch scrobbling if appropriate.
+ updateAllClients();
}
}

0 comments on commit edb307f

Please sign in to comment.