diff --git a/plugin.xml b/plugin.xml index de207dd..7c5f98e 100644 --- a/plugin.xml +++ b/plugin.xml @@ -6,7 +6,6 @@ - RemoteControls lockscreen,media,now,playing https://github.com/shi11/RemoteControls.git @@ -17,11 +16,8 @@ - - Seth Hillinger, François LASSERRE, Michael GAUTHIER - + Seth Hillinger, François LASSERRE, Michael GAUTHIER, Guilherme Chaguri MIT License - @@ -34,5 +30,26 @@ - - + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/android/RemoteControls.java b/src/android/RemoteControls.java new file mode 100644 index 0000000..a1effed --- /dev/null +++ b/src/android/RemoteControls.java @@ -0,0 +1,277 @@ +package guichaguri.remotecontrols; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.RatingCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaInterface; +import org.apache.cordova.CordovaPlugin; +import org.apache.cordova.CordovaWebView; +import org.apache.cordova.PluginResult; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * RemoteControls for Android + * @author Guilherme Chaguri + */ +public class RemoteControls extends CordovaPlugin { + + static RemoteControls instance; + + MediaSessionCompat session; + PlaybackStateCompat.Builder pb; + MediaMetadataCompat.Builder md; + + RemoteEventHandler eventHandler; + RemoteEventHandler.RemoteVolumeHandler volumeHandler; + RemoteNotification notification; + + private void init(CordovaInterface cordova) { + instance = this; + + Activity activity = cordova.getActivity(); + + ComponentName name = new ComponentName(activity, RemoteNotification.class); + session = new MediaSessionCompat(activity, "RemoteControls", name, null); + + session.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | + MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); + + pb = new PlaybackStateCompat.Builder(); + md = new MediaMetadataCompat.Builder(); + + eventHandler = new RemoteEventHandler(this); + volumeHandler = eventHandler.new RemoteVolumeHandler(true, 100); + notification = new RemoteNotification(this); + + session.setCallback(eventHandler); + session.setPlaybackToRemote(volumeHandler); + + activity.registerReceiver(notification, new IntentFilter(RemoteNotification.ACTION_BUTTON)); + + activity.startService(new Intent(activity.getBaseContext(), RemoteNotification.NotificationService.class)); + } + + private void destroy() { + notification.remove(); + session.release(); + cordova.getActivity().unregisterReceiver(notification); + + if(instance == this) instance = null; + } + + @Override + public void initialize(CordovaInterface cordova, CordovaWebView webView) { + init(cordova); + } + + @Override + public void onDestroy() { + destroy(); + } + + @Override + public void onReset() { + destroy(); + init(cordova); + } + + @Override + public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { + if(action.equals("updatePlayback")) { + JSONObject obj = args.length() > 0 ? args.getJSONObject(0) : null; + updatePlayback(obj, callbackContext); + return true; + } else if(action.equals("updateMetadata")) { + JSONObject obj = args.length() > 0 ? args.getJSONObject(0) : null; + updateMetadata(obj, callbackContext); + return true; + } else if(action.equals("updateActions")) { + JSONObject obj = args.length() > 0 ? args.getJSONObject(0) : null; + updateActions(obj, callbackContext); + return true; + } else if(action.equals("handleEvents")) { + eventHandler.eventCallback = callbackContext; + PluginResult pluginResult = new PluginResult(PluginResult.Status.NO_RESULT); + pluginResult.setKeepCallback(true); + callbackContext.sendPluginResult(pluginResult); + return true; + } + return false; + } + + private void updateActions(JSONObject obj, CallbackContext callbackContext) throws JSONException { + if(obj != null) { + long actions = 0; + + boolean stop = obj.has("stop") && obj.getBoolean("stop"); + boolean pause = obj.has("pause") && obj.getBoolean("pause"); + boolean play = obj.has("play") && obj.getBoolean("play"); + boolean previous = obj.has("skipToPrevious") && obj.getBoolean("skipToPrevious"); + boolean next = obj.has("skipToNext") && obj.getBoolean("skipToNext"); + + notification.stopButton = stop; + notification.pauseButton = pause; + notification.playButton = play; + notification.previousButton = previous; + notification.nextButton = next; + + if(stop) + actions |= PlaybackStateCompat.ACTION_STOP; + if(pause) + actions |= PlaybackStateCompat.ACTION_PAUSE; + if(play) + actions |= PlaybackStateCompat.ACTION_PLAY; + if(previous) + actions |= PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; + if(next) + actions |= PlaybackStateCompat.ACTION_SKIP_TO_NEXT; + if(obj.has("rate") && obj.getBoolean("rate")) + actions |= PlaybackStateCompat.ACTION_SET_RATING; + if(obj.has("seekTo") && obj.getBoolean("seekTo")) + actions |= PlaybackStateCompat.ACTION_SEEK_TO; + + boolean volumeChangeable = obj.has("volume") && obj.getBoolean("volume"); + if(volumeChangeable != volumeHandler.isChangeable()) { + volumeHandler = eventHandler.new RemoteVolumeHandler(volumeChangeable, volumeHandler.getCurrentVolume()); + session.setPlaybackToRemote(volumeHandler); + } + + pb.setActions(actions); + session.setPlaybackState(pb.build()); + notification.update(); + callbackContext.success("Actions updated"); + } else { + callbackContext.error("Missing properties"); + } + } + + private void updatePlayback(JSONObject obj, CallbackContext callbackContext) throws JSONException { + if(obj != null) { + int state = PlaybackStateCompat.STATE_NONE; + notification.isPlaying = false; + switch(obj.getInt("state")) { + case -1: + state = PlaybackStateCompat.STATE_ERROR; + break; + case 0: + state = PlaybackStateCompat.STATE_STOPPED; + break; + case 1: + state = PlaybackStateCompat.STATE_PLAYING; + notification.isPlaying = true; + break; + case 2: + state = PlaybackStateCompat.STATE_PAUSED; + break; + case 3: + state = PlaybackStateCompat.STATE_BUFFERING; + notification.isPlaying = true; + break; + } + + float speed = obj.has("speed") ? (float)obj.getDouble("speed") : 1; + int elapsedTime = obj.has("elapsedTime") ? obj.getInt("elapsedTime") : 0; + pb.setState(state, elapsedTime, speed); + + if(obj.has("bufferedTime")) + pb.setBufferedPosition(obj.getInt("bufferedTime")); + if(obj.has("volume")) + volumeHandler.setCurrentVolume(obj.getInt("volume")); + + session.setPlaybackState(pb.build()); + notification.update(); + callbackContext.success("Playback updated"); + } else { + callbackContext.error("Missing properties"); + } + } + + private void updateMetadata(final JSONObject obj, final CallbackContext callbackContext) { + if(obj != null) { + cordova.getThreadPool().execute(new Runnable() { + @Override + public void run() { + try { + String artist = obj.getString("artist"); + String title = obj.getString("title"); + + md.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist); + md.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title); + notification.artist = artist; + notification.title = title; + notification.album = null; + notification.cover = null; + notification.color = null; + + if(obj.has("album")) { + String album = obj.getString("album"); + md.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album); + notification.album = album; + } + if(obj.has("genre")) + md.putString(MediaMetadataCompat.METADATA_KEY_GENRE, obj.getString("genre")); + if(obj.has("rating")) + md.putRating(MediaMetadataCompat.METADATA_KEY_RATING, RatingCompat.newPercentageRating(obj.getInt("rating"))); + if(obj.has("description")) + md.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, obj.getString("description")); + if(obj.has("date")) + md.putString(MediaMetadataCompat.METADATA_KEY_DATE, obj.getString("date")); + if(obj.has("color")) + notification.color = obj.getInt("color"); + if(obj.has("duration")) + md.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, obj.getLong("duration")); + if(obj.has("cover")) + loadCover(obj.getString("cover")); + + session.setMetadata(md.build()); + session.setActive(true); + notification.update(); + callbackContext.success("Metadata updated"); + } catch(JSONException e) { + callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.JSON_EXCEPTION)); + } catch(IOException e) { + callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION)); + } + } + }); + } else { + session.setActive(false); + notification.remove(); + callbackContext.success("Metadata removed"); + } + } + + private void loadCover(final String coverUri) throws IOException { + Bitmap cover; + InputStream input; + + if(coverUri.matches("https?:\\/\\/.*")) { // HTTP URL + URLConnection connection = new URL(coverUri).openConnection(); + connection.setDoInput(true); + connection.connect(); + input = connection.getInputStream(); + cover = BitmapFactory.decodeStream(input); + } else { // ASSET FILE + input = cordova.getActivity().getAssets().open("www/" + coverUri); + cover = BitmapFactory.decodeStream(input); + } + + md.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, cover); + notification.cover = cover; + input.close(); + } +} diff --git a/src/android/RemoteEventHandler.java b/src/android/RemoteEventHandler.java new file mode 100644 index 0000000..9376b8c --- /dev/null +++ b/src/android/RemoteEventHandler.java @@ -0,0 +1,86 @@ +package guichaguri.remotecontrols; + +import android.support.v4.media.RatingCompat; +import android.support.v4.media.VolumeProviderCompat; +import android.support.v4.media.session.MediaSessionCompat; +import org.apache.cordova.CallbackContext; +import org.apache.cordova.PluginResult; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * The event handler + * @author Guilherme Chaguri + */ +public class RemoteEventHandler extends MediaSessionCompat.Callback { + private final RemoteControls rc; + CallbackContext eventCallback; + + RemoteEventHandler(RemoteControls rc) { + this.rc = rc; + } + + private void sendEvent(String action, String key, Object value) { + if(eventCallback == null) return; + try { + JSONObject obj = new JSONObject(); + + obj.put("action", action); + if(key != null) obj.put(key, value); + + PluginResult result = new PluginResult(PluginResult.Status.OK, obj); + result.setKeepCallback(true); + eventCallback.sendPluginResult(result); + } catch(JSONException ex) { + ex.printStackTrace(); + } + } + private void sendEvent(String action) { + sendEvent(action, null, null); + } + + @Override + public void onPlay() { + sendEvent("play"); + } + @Override + public void onPause() { + sendEvent("pause"); + } + @Override + public void onStop() { + sendEvent("stop"); + } + @Override + public void onSeekTo(long pos) { + sendEvent("seek", "position", pos); + } + @Override + public void onSkipToPrevious() { + sendEvent("previous"); + } + @Override + public void onSkipToNext() { + sendEvent("next"); + } + @Override + public void onSetRating(RatingCompat rating) { + sendEvent("rate", "rating", rating.getPercentRating()); + } + + class RemoteVolumeHandler extends VolumeProviderCompat { + RemoteVolumeHandler(boolean changeable, int currentVolume) { + super(changeable ? VOLUME_CONTROL_ABSOLUTE : VOLUME_CONTROL_FIXED, 100, currentVolume); + } + + boolean isChangeable() { + return VOLUME_CONTROL_FIXED != getVolumeControl(); + } + + @Override + public void onSetVolumeTo(int volume) { + sendEvent("volume", "volume", volume); + } + } + +} diff --git a/src/android/RemoteNotification.java b/src/android/RemoteNotification.java new file mode 100644 index 0000000..8fa08e1 --- /dev/null +++ b/src/android/RemoteNotification.java @@ -0,0 +1,158 @@ +package guichaguri.remotecontrols; + +import android.app.Activity; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.os.IBinder; +import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.media.session.MediaButtonReceiver; +import android.support.v7.app.NotificationCompat; + +/** + * The Notification Updater + * @author Guilherme Chaguri + */ +public class RemoteNotification extends BroadcastReceiver { + static final String ACTION_BUTTON = "remote-controls-button"; + + private static final int BUTTON_PLAY = 0; + private static final int BUTTON_PAUSE = 1; + private static final int BUTTON_STOP = 2; + private static final int BUTTON_NEXT = 3; + private static final int BUTTON_PREVIOUS = 4; + private static final int BUTTON_REMOVE = 5; + + private final RemoteControls rc; + + String title, album, artist; + Bitmap cover; + Integer color = null; + boolean playButton, pauseButton, stopButton, nextButton, previousButton; + boolean isPlaying; + boolean showWhenPaused = true; + + RemoteNotification(RemoteControls rc) { + this.rc = rc; + } + + void update() { + if(!rc.session.isActive() || (!isPlaying && !showWhenPaused)) { + remove(); + return; + } + Activity activity = rc.cordova.getActivity(); + + NotificationCompat.Builder nc = new NotificationCompat.Builder(activity); + nc.setStyle(new NotificationCompat.MediaStyle().setMediaSession(rc.session.getSessionToken())); + nc.setLargeIcon(cover); + nc.setContentTitle(title); + nc.setContentText(artist); + nc.setContentInfo(album); + nc.setOngoing(isPlaying); + nc.setColor(color == null ? NotificationCompat.COLOR_DEFAULT : color); + + Resources r = activity.getResources(); + String packageName = activity.getPackageName(); + int previous = r.getIdentifier("previous", "drawable", packageName); + int pause = r.getIdentifier("pause", "drawable", packageName); + int play = r.getIdentifier("play", "drawable", packageName); + int stop = r.getIdentifier("stop", "drawable", packageName); + int next = r.getIdentifier("next", "drawable", packageName); + + nc.setSmallIcon(play); + + if(previousButton) + nc.addAction(createAction(activity, previous, "Previous", BUTTON_PREVIOUS)); + if(pauseButton && isPlaying) + nc.addAction(createAction(activity, pause, "Pause", BUTTON_PAUSE)); + if(playButton && !isPlaying) + nc.addAction(createAction(activity, play, "Play", BUTTON_PLAY)); + if(stopButton) + nc.addAction(createAction(activity, stop, "Stop", BUTTON_STOP)); + if(nextButton) + nc.addAction(createAction(activity, next, "Next", BUTTON_NEXT)); + + showWhenPaused = true; + + // Open the app when the notification is clicked + Intent openApp = new Intent(activity, activity.getClass()); + openApp.setAction(Intent.ACTION_MAIN); + openApp.addCategory(Intent.CATEGORY_LAUNCHER); + nc.setContentIntent(PendingIntent.getActivity(activity, 0, openApp, 0)); + + if(!isPlaying) { + // Remove notification + Intent remove = new Intent(ACTION_BUTTON).putExtra("button", BUTTON_REMOVE); + nc.setDeleteIntent(PendingIntent.getBroadcast(activity, BUTTON_REMOVE, remove, 0)); + } + + NotificationManagerCompat.from(activity).notify(0, nc.build()); + } + + void remove() { + NotificationManagerCompat.from(rc.cordova.getActivity()).cancel(0); + } + + private NotificationCompat.Action createAction(Activity activity, int icon, String title, int buttonId) { + Intent intent = new Intent(ACTION_BUTTON).putExtra("button-action", buttonId); + PendingIntent i = PendingIntent.getBroadcast(activity, buttonId, intent, 0); + return new NotificationCompat.Action(icon, title, i); + } + + @Override + public void onReceive(Context context, final Intent intent) { + String action = intent.getAction(); + boolean isButton = action.equals(ACTION_BUTTON) && intent.hasExtra("button-action"); + boolean isMedia = action.equals(Intent.ACTION_MEDIA_BUTTON) && intent.hasExtra(Intent.EXTRA_KEY_EVENT); + if(!isButton && !isMedia) return; + + final int button = isButton ? intent.getIntExtra("button-action", -1) : -1; + + rc.cordova.getThreadPool().execute(new Runnable() { + @Override + public void run() { + if(button == -1) { + MediaButtonReceiver.handleIntent(rc.session, intent); + } else if(button == BUTTON_PLAY) { + rc.eventHandler.onPlay(); + } else if(button == BUTTON_PAUSE) { + rc.eventHandler.onPause(); + } else if(button == BUTTON_STOP) { + rc.eventHandler.onStop(); + } else if(button == BUTTON_NEXT) { + rc.eventHandler.onSkipToNext(); + } else if(button == BUTTON_PREVIOUS) { + rc.eventHandler.onSkipToPrevious(); + } else if(button == BUTTON_REMOVE) { + showWhenPaused = false; + } + } + }); + } + + public static class NotificationService extends Service { + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_NOT_STICKY; + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + if(RemoteControls.instance != null && RemoteControls.instance.notification != null) { + RemoteControls.instance.notification.remove(); + } + stopSelf(); + } + + } +} diff --git a/src/android/res/next.png b/src/android/res/next.png new file mode 100644 index 0000000..3c475d0 Binary files /dev/null and b/src/android/res/next.png differ diff --git a/src/android/res/pause.png b/src/android/res/pause.png new file mode 100644 index 0000000..d631813 Binary files /dev/null and b/src/android/res/pause.png differ diff --git a/src/android/res/play.png b/src/android/res/play.png new file mode 100644 index 0000000..3cf83d7 Binary files /dev/null and b/src/android/res/play.png differ diff --git a/src/android/res/previous.png b/src/android/res/previous.png new file mode 100644 index 0000000..9e841cb Binary files /dev/null and b/src/android/res/previous.png differ diff --git a/src/android/res/stop.png b/src/android/res/stop.png new file mode 100644 index 0000000..3bacfbc Binary files /dev/null and b/src/android/res/stop.png differ diff --git a/www/RemoteControls.js b/www/RemoteControls.js index 0ed7ba0..76b9bf1 100644 --- a/www/RemoteControls.js +++ b/www/RemoteControls.js @@ -10,20 +10,88 @@ // MIT Licensed // - //------------------------------------------------------------------------------ -// object that we're exporting +// Cordova Objects //------------------------------------------------------------------------------ +var cordova = require('cordova'); +var exec = require('cordova/exec'); var remoteControls = module.exports; +//------------------------------------------------------------------------------ +// Update Functions +//------------------------------------------------------------------------------ + //params = [artist, title, album, cover, duration] +// DEPRECATED! Use updateMetadata instead remoteControls.updateMetas = function(success, fail, params) { - cordova.exec(success, fail, 'RemoteControls', 'updateMetas', params); + remoteControls.updateMetadata({ + artist: params[0], + title: params[1], + album: params[2], + cover: params[3], + duration: params[4] + }, success, fail); }; +remoteControls.updateMetadata = function(data, success, fail) { + exec(success, fail, 'RemoteControls', 'updateMetadata', [data]); +}; + +remoteControls.updatePlayback = function(data, success, fail) { + exec(success, fail, 'RemoteControls', 'updatePlayback', [data]); +}; + +remoteControls.updateActions = function(data, success, fail) { + exec(success, fail, 'RemoteControls', 'updateActions', [data]); +}; + +//------------------------------------------------------------------------------ +// Event Handling +//------------------------------------------------------------------------------ + +// DEPRECATED (remove it as soon as possible) remoteControls.receiveRemoteEvent = function(event) { var ev = document.createEvent('HTMLEvents'); ev.remoteEvent = event; ev.initEvent('remote-event', true, true, arguments); document.dispatchEvent(ev); -} \ No newline at end of file + + var action = event.subtype; + if(action == 'prevTrack') { + action = 'previous'; + } else if(action == 'nextTrack') { + action = 'next'; + } + cordova.fireWindowEvent('remotecontrols', {action: action}); +}; + +remoteControls._eventHandler = cordova.addWindowEventHandler('remotecontrols'); +remoteControls._eventHandlerRegistered = false; + +remoteControls._fireOldEvent = function(obj) { + var subtype = obj.action; + if(subtype == 'previous') { + subtype = 'prevTrack'; + } else if(subtype == 'next') { + subtype = 'nextTrack'; + } + + var ev = document.createEvent('HTMLEvents'); + ev.remoteEvent = {subtype: subtype}; + ev.initEvent('remote-event', true, true); + document.dispatchEvent(ev); +}; + +remoteControls._fireEvent = function(obj) { + // Fire the event + cordova.fireWindowEvent('remotecontrols', obj); + + remoteControls._fireOldEvent(obj); +}; + +remoteControls._eventHandler.onHasSubscribersChange = function() { + if(remoteControls._eventHandler.numHandlers > 0 && !remoteControls._eventHandlerRegistered) { + exec(remoteControls._fireEvent, null, 'RemoteControls', 'handleEvents', []); + remoteControls._eventHandlerRegistered = true; + } +};