#Ti.AudioControls
For an AudioPlayer inside an app we need "remote" controls to watch meta details and control (play/stop/skip). We use three ways:
- listener on "keyboard" on a headset
- Widget over lockscreen (only available for older devices)
- Widget in notification (only available for newer devices)
The left screenshot shows the lockscreen widget for older devices. The buttons comes from system. The other screenshots illustrate the modern notifications. The symbols comes from res/drawable folder and can change by coder on Titanium side.
In future version you can change at runtime. Propose you have podacsts/radiostations with different color schemas, you can put all icons in the folder and select the color by selecting of resource.
The big image (album cover etc.) use remote URL as address. It will be cached by system (make it sense?). The small icon on the right bottom corner and on statusbar must be with white background and comes from res folder too. It is modifyable. You can control the background color of this icon from JS side. Default is a dark gray.
###Headphone buttons If we nothing do then the following automatism is working: if a headphone with 4 contacts is plugged, then the clicking of main button (in the middle) starts the player which has focus. Therefore the Titanium AudioPlayer can control with it. If you need an other behaviour (usage of double click for other function), then you can use Ti.HeadphoneButtons.
###Widgets For remote controling you can use a strip in notification tray (prefered solution) or a widget over lockscreen. This lockscreen widget is only available for older devices. Since Lollipop this feature will not supported.
This is the Javascript interface:
var AudioControls = require("de.appwerft.audiocontrols");
var Lorem = require("vendor/loremipsum"); // for generating random text
AudioControls.createRemoteAudioControl({
onClick : function(_event) {
console.log(_event);
},
lollipop : AudioControls.NOTIFICATION,
lockscreenWidgetVerticalPosition : WIDGET_POSITION_BOTTOM, // only for older devices
iconBackgroundColor : "#ff0000",
vibrate : 20, //duration of vibrate on click in ms, default 20
hasProgress : false, // default false
hasActions : true // default true
});
AudioControls.updateRemoteAudioControl({
image : "http://lorempixel.com/120/120/cats" + "?_=" + Math.random(),
artist : Lorem(10),
title : Lorem(2)
});
setInterval(function() {
AudioControls.updateRemoteAudioControl({
image : "http://lorempixel.com/120/120/cats" + "?_=" + Math.random(),
artist : Lorem(12),
title : Lorem(4)
});
}, 30000);
setInterval(function() {
AudioControls.setProgress(Math.random());
}, 300);
Lollipop devices (API level 21/22) supports both types of widget, with the parameter 'lollipop' the user can select one type. Default is Notification.
##Technical realisation
The widgets are not "normal" views. They use underlaying RemoteViews. For notifications we have three possibilties to realize it:
- custome style, this solution uses RemoteViews too.
- standard style with added actions (buttons), this module uses this solution,
- usage of MediaStyle. This solution generates a ready to use solution and controls the player internally. We wouldn't works with callbacks, but this solution needs for reference a MediaSession. Maybe next version of Ti.Media.AudioPlayer supports this.
For moderner devices (Android 4.1) we could use BigViewStyle, in this case we could add Viualization to widget etc. (Maybe in future versions of this module). For realizing this we use the property contentView
and bind it to a RemoteViews, which will created from a own XML layout.
##Services We could create the notificaction inside our module, but it isn't a clean solution. Better is the create the notification and widget in an own service. For this we create a BroadcastReceiver inside the service. This receiver receives new commands from module to modify etc. the notification. The same way is for hiding and killing the stuff.
###Importing of Javascript stuff First we import all stuff. All of the left side vars are static vars of AudiocontrolsModule class:
private void getOptions(KrollDict opts) {
if (opts != null && opts instanceof KrollDict) {
if (opts.containsKeyAndNotNull("hasActions")) {
hasActions = opts.getBoolean("hasActions");
}
if (opts.containsKeyAndNotNull("title")) {
title = opts.getString("title");
}
if (opts.containsKeyAndNotNull("artist")) {
artist = opts.getString("artist");
}
if (opts.containsKeyAndNotNull("image")) {
image = opts.getString("image");
}
if (opts.containsKeyAndNotNull("lockscreenWidgetVerticalPosition")) {
lockscreenWidgetVerticalPosition = opts
.getInt("lockscreenWidgetVerticalPosition");
}
if (opts.containsKeyAndNotNull("lollipop")) {
lollipop = opts.getInt("lollipop");
}
if (opts.containsKeyAndNotNull("hasProgress")) {
hasProgress = opts.getBoolean("hasProgress");
}
try {
if (opts.containsKeyAndNotNull("iconBackgroundColor")) {
iconBackgroundColor = Color.parseColor(opts
.getString("iconBackgroundColor"));
}
} catch (Exception e) {
e.printStackTrace();
}
if (opts.containsKeyAndNotNull("state")) {
state = opts.getInt("state");
}
/* callback for buttons */
if (opts.containsKeyAndNotNull("onClick")) {
Object cb = opts.get("onClick");
if (cb instanceof KrollFunction) {
onKeypressedCallback = (KrollFunction) cb;
}
}
}
}
```
"iconBackgroundColor" needs a special import because the String maybe is not conform with the right pattern for parsing to a color.
"cb instanceof KrollFunction" tests if on Javascript layer is really a function. Only in this case it will imported.
###Starting the service
```java
Intent intent = new Intent(ctx, NotificationCompactService.class);
if (title != null)
intent.putExtra("title", title);
if (artist != null)
intent.putExtra("artist", artist);
if (image != null)
intent.putExtra("image", image);
intent.putExtra("hasActions", hasActions);
intent.putExtra("hasProgress", hasProgress);
intent.putExtra("iconBackgroundColor", iconBackgroundColor);
intent.putExtra("state", Integer.toString(state));
intent.putExtra("state", Integer.toString(state));
ctx.startService(intent);
```
For intents we have a lot of possibilities of addressing. In this case we adress the class directly. Extras are lightweights paramters. Like javascript we are responsible for typing, If we put i.e. a boolean into, we have to read it on the other side (inside the service) to a boolean.
###Inside the service
The service has two "entrypoints":
1. onCreate() == will call only once at createing time
2. onStartCommand() == will every time called if we start the service in module. Therefore we can use the same method in module for creating and updating
###Service :: onCreate()
The only function is to start a Brodcastreceiver class inside of the service as inner class to receive following "commands" from module.
```java
@Override
public void onCreate() {
notificationServiceReceiver = new NotificationServiceReceiver();
IntentFilter filter = new IntentFilter();
filter.addAction(ACTION);
ctx.registerReceiver(notificationServiceReceiver, filter);
super.onCreate();
}
```
After instantiating of class we have to register the receiver to context. For filtering we use the action ACTION. Additional we need an empty filter.
###Service :: onStartCommand()
This method will called by every update from javascript layer. If the service is dead it will restarted by system and the method onCreate will recalled.
The method onStartCommand has as parameter the intent. This is tha payload.
```java
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (builder == null)
createNotification(intent.getExtras());
if (intent != null && intent.hasExtra("title")) {
updateNotification(intent.getExtras());
}
return START_NOT_STICKY;
}
```
The return var descripes the behaviour of service. "intent.getExtras()" give us a bundle. This bundle cann read in next method:
###createNotification()
```java
private void createNotification(Bundle bundle) {
hasProgress = bundle.getBoolean("hasProgress");
final boolean hasActions = bundle.getBoolean("hasActions");
final int iconBackgroundColor = bundle.getInt("iconBackgroundColor");
final int state = bundle.getInt("state")
notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
builder = new NotificationCompat.Builder(ctx);
builder.setSmallIcon(R("notification_icon", "drawable"))
.setAutoCancel(false)
.setOngoing(true)
.setColor(iconBackgroundColor)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentTitle("ContentTitle")
.setContentText("ContentText");
if (hasProgress) {
builder.setProgress(100, 0, false);
}
if (hasActions) {
bigTextNotification = new NotificationCompat.BigTextStyle();
builder.setStyle(bigTextNotification);
setAudioControlActions(state);
}
notificationManager.notify(NOTIFICATION_ID, builder.build());
}
```
Notifications are configuration monsters:
1. setSmallIcon: is mandatory, it is the icon in sattus bar and the small in right bottom corner of LargeIcon
2. setAutoCancel: clicking on notification don't kill it
3. setOngoing: user cannot swipe out, we can only programmatically remove it
4. setColor: this is the background color of circle behind our small icon
5. setPriority: self explaining
6. setContentTitle: title, big text
7. setContentText: subtitle text
8. setProgress: enables the progressBar with max value, value and the flag if undefined
9. setStyle: this we need for adding action buttons
And with
###setAudioControlActions()
we add our control buttons depending of state of player.
```java
private void setAudioControlActions(int state) {
String id = (state == AudiocontrolsModule.STATE_PLAYING) ? "ic_stop" : "ic_play";
builder.mActions.clear();
builder.addAction(R("ic_prev", "drawable"), "", createPendingIntent("prev"));
builder.addAction(R(id, "drawable"), "", createPendingIntent("play"));
builder.addAction(R("ic_next", "drawable"), "",
createPendingIntent("next"));
}
```
WTF is this R? In standard ansdroid this is a class, created from XML in res folder. In Titanium world we cannot use it and therefor we use a custome method:
```java
private int R(String name, String type) {
int id = 0;
try {
id = ctx.getResources().getIdentifier(name, type,
ctx.getPackageName());
} catch (Exception e) {
return id;
}
return id;
}
```
This method converts the both Strings ito to right integer. This takes longer time as the native version of R and thereforeit is a good idea to resolve all ids in constructor of class.
###Pendingintends for sending event back to module
An Pendingintent is an intent thats works "later". A standard intent will call immediately, bit this kind if the user clicks the buttons.
```java
private PendingIntent createPendingIntent(String msg) {
Intent intent = new Intent();
intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES);
intent.setAction(getPackageName());
intent.putExtra(AudiocontrolsModule.AUDIOCONTROL_COMMAND, msg);
return PendingIntent.getBroadcast(ctx,(int) System.currentTimeMillis(), intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
```
PendingIntents can send to activities or to BroadcastReceivers. In our case we send to a Broadcastreceiver inside our module AudiocontrolsModule. The action is our packageName. In this case only our app can listen to the event. We need for every button a Pendingintent.
###Receiving button clicks from service
Following BroadcastReceiver will instantiate and registered inside the creater of widget. It listens both events: from lockscreen widget and from notification.
```java
if (remoteAudioControlEventLister == null) {
remoteAudioControlEventLister = new RemoteAudioControlEventLister();
IntentFilter filter = new IntentFilter(ctx.getPackageName());
ctx.registerReceiver(remoteAudioControlEventLister, filter);
}
```
We only listen to our signals and don't filter anything.
```java
public class RemoteAudioControlEventLister extends BroadcastReceiver {
@Override
public void onReceive(Context ctx, Intent intent) {
if (intent.getStringExtra(AUDIOCONTROL_COMMAND) != null) {
if (vibrate > 0) {
Vibrator v = (Vibrator) ctx.getSystemService(Context.VIBRATOR_SERVICE);
v.vibrate(vibrate);
}
KrollDict dict = new KrollDict();
dict.put("cmd", intent.getStringExtra(AUDIOCONTROL_COMMAND));
if (onClickCallback != null) {
onClickCallback.call(getKrollObject(), dict);
}
}
}
}
```
###Hiding Widget
For this act we have this javascript interface:
```javascript
hideRemoteAudioControl()
```
This function matches to this java method:
```java
@Kroll.method
public void hideRemoteAudioControl() {
Intent intent = new Intent(ctx.getPackageName());
intent.setAction(NotificationCompactService.ACTION);
intent.putExtra(AudioControlsModule.SERVICE_COMMAND_KEY, AudioControlsModule..RQS_REMOVE_NOTIFICATION);
ctx.sendBroadcast(intent);
Log.d(LCAT, "RQS_STOP_SERVICE sent");
}
```
Inside the *NotificationCompactService* this intent will catched by:
```java
public class NotificationServiceReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context ctx, Intent intent) {
if (intent.hasExtra(AudioControlsModule.SERVICE_COMMAND_KEY)) {
int rqs = intent.getIntExtra(AudioControlsModule.SERVICE_COMMAND_KEY, 0);
if (rqs == AudioControlsModule.RQS_REMOVE_NOTIFICATION) {
notificationManager.cancel(NOTIFICATION_ID);
}
}
}
}
```
After catching the line "notificationManager.cancel(NOTIFICATION_ID);" hides the notification.
In *LockScreenService* it works similar:
```java
public class LockScreenServiceReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context ctx, Intent intent) {
if (intent.hasExtra(AudioControlsModule.SERVICE_COMMAND_KEY)) {
int rqs = intent.getIntExtra(AudioControlsModule.SERVICE_COMMAND_KEY, 0);
if (rqs == AudioControlsModule.RQS_REMOVE_NOTIFICATION) {
if (isShowing) {
windowManager.removeView(audioControlWidget);
isShowing = false;
}
}
}
}
}
```
##LockScreenWidget
This widget is not an activity, it is a RemoteView that is controlled by windowmanager. The Android WindowManager is a system service, which is responsible for managing the z-ordered list of windows, which windows are visible, and how they are laid out on screen. If you open a new activity by intent then the WindowManager will call internally, but for our purpose we can speak directly. Like notification the WindowManager works with RemoteViews.
###onCreate()
Here we build the widget by calling an own class *AudioControlWidget* and create two BroadcastReceiver:
1. LockscreenStateReceiver: for detecting system events ACTION_SCREEN_OFF and ACTION_USER_PRESENT to show/hide the widget
2. LockscreenServiceReceiver: for communicating with AudiocontrolsModule
The WindowManger works in this configuration:
```java
public void onCreate() {
super.onCreate();
widgetVisible = true;
lockscreenServiceReceiver = new LockscreenServiceReceiver();
IntentFilter filter = new IntentFilter();
filter.addAction(ACTION);
ctx.registerReceiver(lockscreenServiceReceiver, filter);
audioControlWidget = new AudioControlWidget(ctx);
windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
final int flags = WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
final int type = WindowManager.LayoutParams.TYPE_SYSTEM_ERROR;
final int HEIGHT = 155;
layoutParams = new WindowManager.LayoutParams(WindowManager.LayoutParams.FILL_PARENT, HEIGHT, type, flags, PixelFormat.TRANSLUCENT);
layoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
appProperties = TiApplication.getInstance().getAppProperties();
String verticalAlign = appProperties.getString("PLAYER_VERTICAL_POSITION", "BOTTOM");
layoutParams.gravity = (verticalAlign == "TOP") ? Gravity.TOP : Gravity.BOTTOM;
layoutParams.alpha = 0.95f;
lockScreenStateReceiver = new LockScreenStateReceiver();
IntentFilter mfilter = new IntentFilter(Intent.ACTION_SCREEN_OFF);
mfilter.addAction(Intent.ACTION_USER_PRESENT);
ctx.registerReceiver(lockScreenStateReceiver, mfilter);
}
```
Important is the paramter *TYPE_SYSTEM_ERROR*
The class *AudioControlWidget* is a standard view class. The back communication works withstandard intent. The three buttons has as third paramter the *buttonListener*:
```java
private OnClickListener buttonListener = new OnClickListener() {
@Override
public void onClick(View clicksource) {
int id = clicksource.getId();
String msg = "";
if (id == prevCtrlId) msg = "rewind";
if (id == nextCtrlId) msg = "forward";
if (id == playCtrlId) msg = "play";
Intent intent = new Intent();
intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES);
intent.setAction(ctx.getPackageName());
intent.putExtra(AudiocontrolsModule.AUDIOCONTROL_COMMAND, msg);
ctx.sendBroadcast(intent);
}
};
```
###Loading of remote images
In both widget we can use the awesome library picasso. First we download the jar, copy to lib folder and add to compile path:
<img src="images/Screen Shot 2016-08-10 at 16.39.55.png" width=600 >
Don't forget to install *okhttp* and *ok.io*.
####LockscreenWidget
First we test the syntax of URL and then the main action:
```java
try {
@SuppressWarnings("unused")
URL dummy = new URL(imageUrl);
Picasso.with(ctx).load(imageUrl).placeholder(placeholderId).into(this.coverView);
} catch (MalformedURLException e) {
e.printStackTrace();
}
```
####NotificationWidget
First we create a new target and then we call the downlaod with *load(image)*. In *onBitmapLoaded* weset the bitmap to largeIcon and force the rerendering by calling of *notify*.
```
final Target target = new Target() {
@Override
public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) {
builder.setLargeIcon(bitmap);
notificationManager.notify(NOTIFICATION_ID, builder.build());
}
};
Picasso.with(ctx).load(image).into(target);
```
##Calling app by click on notification
With `setContentIntent(pendIntent)` you can do something if user clicks on notification:
```java
Intent notifyIntent = new Intent(context, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, notifyIntent, PendingIntent.FLAG_UPDATE_CURRENT);
notifmanager.setContentIntent(pendingIntent);
```