/*-
* Copyright (C) 2011 Peter Baldwin
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.peterbaldwin.vlcremote.service;
import android.app.Service;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Message;
import android.os.Process;
import android.util.Log;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import org.peterbaldwin.client.android.vlcremote.MediaAppWidgetProvider;
import org.peterbaldwin.client.android.vlcremote.R;
import org.peterbaldwin.vlcremote.intent.Intents;
import org.peterbaldwin.vlcremote.model.Preferences;
import org.peterbaldwin.vlcremote.model.Status;
import org.peterbaldwin.vlcremote.net.MediaServer;
import org.peterbaldwin.vlcremote.net.json.JsonContentHandler;
import org.peterbaldwin.vlcremote.widget.NotificationControls;
/**
* Sends commands to a VLC server and receives & broadcasts the status.
*/
public class StatusService extends Service implements Handler.Callback {
public static boolean USE_XML_STATUS = false;
private static boolean HAS_CANCELLED_NOTIFICATION;
private static final String TAG = "StatusService";
private static final int REMOTE_STATUS = 0;
private static final int REMOTE_ERROR = -1;
private static final int HANDLE_STATUS = 1;
private static final int HANDLE_ALBUM_ART = 2;
private static final int HANDLE_STOP = 3;
private static final int HANDLE_REMOTE_VIEWS = 4;
private static final int HANDLE_NOTIFICATION_CREATE = 5;
private static boolean isCommand(Uri uri) {
return !uri.getQueryParameters("command").isEmpty();
}
/**
* Erases any commands from the URI.
*/
private static Uri readOnly(Uri uri) {
Uri.Builder builder = uri.buildUpon();
builder.encodedQuery("");
return builder.build();
}
private static boolean isSeek(Uri uri) {
return "seek".equals(uri.getQueryParameter("command"));
}
private static boolean isVolume(Uri uri) {
return "volume".equals(uri.getQueryParameter("command"));
}
private static boolean isAbsoluteValue(Uri uri) {
String value = uri.getQueryParameter("val");
return value != null && !value.startsWith("+") && !value.startsWith("-");
}
private String mAuthority;
private Handler mStatusHandler;
private Handler mAlbumArtHandler;
private Handler mCommandHandler;
private Handler mRemoteViewsHandler;
private AtomicInteger mSequenceNumber;
private String mLastState;
private String mLastFileName;
private boolean mUpdateRemoteViews;
@Override
public void onCreate() {
super.onCreate();
mSequenceNumber = new AtomicInteger();
mRemoteViewsHandler = startHandlerThread("RemoteViewsThread");
mStatusHandler = startHandlerThread("StatusThread");
// Create a separate thread for album art requests
// because the request can be very slow.
mAlbumArtHandler = startHandlerThread("AlbumArtThread");
// Create a separate thread for commands to improve latency
// (commands shouldn't have to wait for partially complete reads).
mCommandHandler = startHandlerThread("CommandThread");
}
@Override
public void onDestroy() {
stopHandlerThread(mRemoteViewsHandler);
stopHandlerThread(mStatusHandler);
stopHandlerThread(mCommandHandler);
stopHandlerThread(mAlbumArtHandler);
super.onDestroy();
}
private Handler startHandlerThread(String name) {
HandlerThread thread = new HandlerThread(name, Process.THREAD_PRIORITY_BACKGROUND);
thread.start();
return new Handler(thread.getLooper(), this);
}
private void stopHandlerThread(Handler handler) {
handler.getLooper().quit();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = (intent != null) ? intent.getAction() : null;
Uri uri = (intent != null) ? intent.getData() : null;
if (Intents.ACTION_STATUS.equals(action) && uri != null) {
if (isCommand(uri)) {
// A command will change the status, so cancel any unsent
// requests to query the status
mStatusHandler.removeMessages(HANDLE_STATUS);
}
if (isSeek(uri) || isVolume(uri)) {
if (isAbsoluteValue(uri)) {
// Seeking to an absolute position or volume invalidates
// any existing requests to change the position or volume
mCommandHandler.removeMessages(HANDLE_STATUS);
}
}
Handler handler = isCommand(uri) ? mCommandHandler : mStatusHandler;
if (isCommand(uri) || !handler.hasMessages(HANDLE_STATUS)) {
int sequenceNumber = isCommand(uri) ? mSequenceNumber.incrementAndGet()
: mSequenceNumber.get();
int extraFlags = intent.getIntExtra(Intents.EXTRA_FLAGS, 0);
Message msg = handler.obtainMessage(HANDLE_STATUS, sequenceNumber, extraFlags, uri);
handler.sendMessage(msg);
}
} else if (Intents.ACTION_ART.equals(action) && uri != null) {
int seqNumber = mSequenceNumber.get();
mAlbumArtHandler.obtainMessage(HANDLE_ALBUM_ART, seqNumber, -1, uri).sendToTarget();
} else if (Intents.ACTION_NOTIFICATION_CANCEL.equals(action)) {
NotificationControls.cancel(this);
HAS_CANCELLED_NOTIFICATION = true;
} else if (Intents.ACTION_NOTIFICATION_CREATE.equals(action)) {
mRemoteViewsHandler.obtainMessage(HANDLE_NOTIFICATION_CREATE).sendToTarget();
} else if (Intents.ACTION_PROGRAMMATIC_APPWIDGET_UPDATE.equals(action)) {
mUpdateRemoteViews = true;
sendStatusRequest();
}
// Stop the service if no new Intents are received for 20 seconds
Handler handler = mCommandHandler;
Message msg = handler.obtainMessage(HANDLE_STOP, startId, -1);
handler.sendMessageDelayed(msg, 20 * 1000);
return START_STICKY;
}
@Override
public boolean handleMessage(Message msg) {
switch (msg.what) {
case HANDLE_STATUS:
handleStatus(msg);
return true;
case HANDLE_ALBUM_ART:
handleArt(msg);
return true;
case HANDLE_NOTIFICATION_CREATE:
NotificationControls.showLoading(this);
mUpdateRemoteViews = true;
HAS_CANCELLED_NOTIFICATION = false;
sendStatusRequest();
return true;
case HANDLE_REMOTE_VIEWS:
handleRemoteViews(msg);
return true;
case HANDLE_STOP:
int startId = msg.arg1;
stopSelf(startId);
return true;
default:
return false;
}
}
private void handleStatus(Message msg) {
Uri uri = (Uri) msg.obj;
MediaServer server = new MediaServer(this, uri);
if(!server.getAuthority().equals(mAuthority)) {
mAuthority = server.getAuthority();
USE_XML_STATUS = false; // new server so try json first
}
int seqNumber = msg.arg1;
int flags = msg.arg2;
if (seqNumber == mSequenceNumber.get()) {
boolean setResumeOnIdle = ((flags & Intents.FLAG_SET_RESUME_ON_IDLE) != 0);
boolean onlyIfPlaying = ((flags & Intents.FLAG_ONLY_IF_PLAYING) != 0);
boolean onlyIfPaused = ((flags & Intents.FLAG_ONLY_IF_PAUSED) != 0);
try {
if (onlyIfPlaying || onlyIfPaused) {
Status status = server.status().read();
if (onlyIfPlaying && !status.isPlaying()) {
return;
}
if (onlyIfPaused && !status.isPaused()) {
return;
}
}
Preferences pref = Preferences.get(this);
Status status = server.status(uri).read();
if (seqNumber == mSequenceNumber.get()) {
sendBroadcast(Intents.status(status));
Message n = mRemoteViewsHandler.obtainMessage(HANDLE_REMOTE_VIEWS, seqNumber, REMOTE_STATUS, status);
n.sendToTarget();
if (isCommand(uri)) {
// Check the status again after the command has had time to take effect.
msg = mStatusHandler.obtainMessage(HANDLE_STATUS, seqNumber, 0, readOnly(uri));
mStatusHandler.sendMessageDelayed(msg, 500);
}
} else {
Log.d(TAG, "Dropped stale status response: " + uri);
}
if (setResumeOnIdle) {
pref.setResumeOnIdle();
}
} catch (Throwable tr) {
if(JsonContentHandler.FILE_NOT_FOUND.equals(tr.getMessage())) {
USE_XML_STATUS = true;
}
Log.e(TAG, "Error: " + tr.getMessage());
Intent broadcast = Intents.error(tr);
broadcast.putExtra(Intents.EXTRA_FLAGS, flags);
sendBroadcast(broadcast);
mRemoteViewsHandler.obtainMessage(HANDLE_REMOTE_VIEWS, seqNumber, REMOTE_ERROR, tr).sendToTarget();
}
} else {
Log.d(TAG, "Dropped stale status request: " + uri);
}
}
private void handleArt(Message msg) {
Uri uri = (Uri) msg.obj;
MediaServer server = new MediaServer(this, uri);
int sequenceNumber = msg.arg1;
if (sequenceNumber == mSequenceNumber.get()) {
try {
Bitmap bitmap = server.image(uri).read();
if (sequenceNumber == mSequenceNumber.get()) {
sendBroadcast(Intents.art(bitmap));
} else {
Log.d(TAG, "Dropped stale album art response: " + uri);
}
} catch (Throwable tr) {
String message = String.valueOf(tr);
Log.e(TAG, message, tr);
sendBroadcast(Intents.error(tr));
}
} else {
Log.d(TAG, "Dropped stale album art request: " + uri);
}
}
private void handleRemoteViews(Message msg) {
int seqNumber = msg.arg1;
if (seqNumber != mSequenceNumber.get()) {
return;
}
Preferences pref = Preferences.get(this);
if(REMOTE_STATUS == msg.arg2) {
Status status = (Status) msg.obj;
MediaAppWidgetProvider.scheduleUpdate(this, status);
handleRemoteViewsStatus(status, pref);
} else if(REMOTE_ERROR == msg.arg2) {
handleRemoteViewsError((Throwable) msg.obj, pref);
}
mUpdateRemoteViews = false;
}
private void handleRemoteViewsStatus(Status status, Preferences pref) {
if(!checkStatusChanged(status) && !mUpdateRemoteViews) {
return;
}
int[] widgetIds = MediaAppWidgetProvider.getWidgetIds(this);
if(widgetIds.length == 0 && (!pref.isNotificationSet() || HAS_CANCELLED_NOTIFICATION)) {
return;
}
MediaServer server = new MediaServer(this, pref.getAuthority());
Bitmap bitmap;
try {
bitmap = server.art().read();
} catch(IOException ex) {
bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.albumart_mp_unknown);
}
if(pref.isNotificationSet() && !HAS_CANCELLED_NOTIFICATION) {
NotificationControls.show(this, status, bitmap);
}
if(widgetIds.length != 0) {
MediaAppWidgetProvider.update(this, status, bitmap);
}
}
private void handleRemoteViewsError(Throwable tr, Preferences pref) {
MediaAppWidgetProvider.cancelPendingUpdate(this);
int[] widgetIds = MediaAppWidgetProvider.getWidgetIds(this);
if(widgetIds.length == 0 && (!pref.isNotificationSet() || HAS_CANCELLED_NOTIFICATION)) {
return;
}
if(pref.isNotificationSet() && !HAS_CANCELLED_NOTIFICATION) {
NotificationControls.showError(this, tr);
}
if(widgetIds.length != 0) {
MediaAppWidgetProvider.update(this, tr);
}
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void sendStatusRequest() {
MediaServer server = new MediaServer(this, Preferences.get(this).getAuthority());
if(server.getAuthority() != null) {
server.status().get();
}
}
/**
* Check if the playback status has changed. If the status has changed,
* the new state will be stored.
* @param status Status
* @return true if status was changed, false otherwise
*/
private boolean checkStatusChanged(Status status) {
if(!status.equalsState(mLastFileName, mLastState)) {
mLastFileName = status.getTrack().getName();
mLastState = status.getState();
return true;
}
return false;
}
}