diff --git a/build.gradle b/build.gradle
index 5ab8675a..7e09abb6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,96 +1,97 @@
buildscript {
repositories {
jcenter()
maven {
url 'https://maven.google.com/'
name 'Google'
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.0.1'
}
}
apply plugin: 'com.android.application'
android {
buildToolsVersion '26.0.2'
compileSdkVersion 25
defaultConfig {
minSdkVersion 9
targetSdkVersion 25
//multiDexEnabled true
//testInstrumentationRunner "com.android.test.runner.MultiDexTestRunner"
}
dexOptions {
javaMaxHeapSize "2g"
}
compileOptions {
// Use Java 1.7, requires minSdk 8
//SSHD requires mina when running on JDK < 7
sourceCompatibility JavaVersion.VERSION_1_7
targetCompatibility JavaVersion.VERSION_1_7
}
sourceSets {
main {
manifest.srcFile 'AndroidManifest.xml'
java.srcDirs = ['src']
resources.srcDirs = ['resources']
res.srcDirs = ['res']
assets.srcDirs = ['assets']
}
androidTest {
java.srcDirs = ['tests']
}
}
packagingOptions {
pickFirst "META-INF/DEPENDENCIES"
pickFirst "META-INF/LICENSE"
pickFirst "META-INF/NOTICE"
pickFirst "META-INF/BCKEY.SF"
pickFirst "META-INF/BCKEY.DSA"
pickFirst "META-INF/INDEX.LIST"
pickFirst "META-INF/io.netty.versions.properties"
}
lintOptions {
abortOnError false
checkReleaseBuilds false
}
buildTypes {
debug {
minifyEnabled false
useProguard false
}
release { //keep on 'release', set to 'all' when testing to make sure proguard is not deleting important stuff
minifyEnabled true
useProguard true
proguardFiles getDefaultProguardFile('proguard-android.txt'),'proguard-rules.pro'
}
}
}
dependencies {
repositories {
jcenter()
maven {
url 'https://maven.google.com/'
name 'Google'
}
}
implementation 'com.android.support:support-v4:25.4.0'
implementation 'com.android.support:appcompat-v7:25.4.0'
implementation 'com.android.support:design:25.4.0'
+ implementation 'com.jakewharton:disklrucache:2.0.2' //For caching album art bitmaps
implementation 'org.apache.sshd:sshd-core:0.8.0' //0.9 seems to fail on Android 6 and 1.+ requires java.nio.file, which doesn't exist in Android
implementation 'com.madgag.spongycastle:pkix:1.54.0.0' //For SSL certificate generation
// Testing
androidTestImplementation 'org.mockito:mockito-core:1.10.19'
androidTestImplementation 'com.google.dexmaker:dexmaker-mockito:1.1'// Because mockito has some problems with dex environment
androidTestImplementation 'org.skyscreamer:jsonassert:1.3.0'
testImplementation 'junit:junit:4.12'
}
diff --git a/res/drawable/ic_album_art_placeholder.xml b/res/drawable/ic_album_art_placeholder.xml
new file mode 100644
index 00000000..be5f1214
--- /dev/null
+++ b/res/drawable/ic_album_art_placeholder.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/res/layout-land/activity_mpris.xml b/res/layout-land/activity_mpris.xml
new file mode 100644
index 00000000..b79bd807
--- /dev/null
+++ b/res/layout-land/activity_mpris.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/res/layout/activity_mpris.xml b/res/layout/activity_mpris.xml
new file mode 100644
index 00000000..07c52ef2
--- /dev/null
+++ b/res/layout/activity_mpris.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
diff --git a/res/layout/mpris_control.xml b/res/layout/mpris_control.xml
index 70d47b1a..aeb41169 100644
--- a/res/layout/mpris_control.xml
+++ b/res/layout/mpris_control.xml
@@ -1,184 +1,181 @@
+ android:layout_gravity="center">
diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java
new file mode 100644
index 00000000..f15e81f3
--- /dev/null
+++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java
@@ -0,0 +1,428 @@
+/*
+ * Copyright 2017 Matthijs Tijink
+ *
+ * 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 2 of
+ * the License or (at your option) version 3 or any later version
+ * accepted by the membership of KDE e.V. (or its successor approved
+ * by the membership of KDE e.V.), which shall act as a proxy
+ * defined in Section 14 of version 3 of the license.
+ *
+ * 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 .
+*/
+
+package org.kde.kdeconnect.Plugins.MprisPlugin;
+
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.os.AsyncTask;
+import android.support.v4.util.LruCache;
+import android.util.Log;
+
+import com.jakewharton.disklrucache.DiskLruCache;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+
+/**
+ * Handles the cache for album art
+ */
+public final class AlbumArtCache {
+ private static final class MemoryCacheItem {
+ boolean failedFetch;
+ Bitmap albumArt;
+ }
+
+ /**
+ * An in-memory cache for album art bitmaps. Holds at most 10 entries (to prevent too much memory usage)
+ * Also remembers failure to fetch urls.
+ */
+ private static final LruCache memoryCache = new LruCache<>(10);
+ /**
+ * An on-disk cache for album art bitmaps.
+ */
+ private static DiskLruCache diskCache;
+
+ /**
+ * A list of urls yet to be fetched.
+ */
+ private static final ArrayList fetchUrlList = new ArrayList<>();
+ /**
+ * A integer indicating how many fetches are in progress.
+ */
+ private static int numFetching = 0;
+
+ /**
+ * A list of plugins to notify on fetched album art
+ */
+ private static ArrayList registeredPlugins = new ArrayList<>();
+
+ /**
+ * Initializes the disk cache. Needs to be called at least once before trying to use the cache
+ * @param context The context
+ */
+ public static void initializeDiskCache(Context context) {
+ if (diskCache != null) return;
+
+ File cacheDir = new File(context.getCacheDir(), "album_art");
+ int versionCode;
+ try {
+ PackageInfo info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
+ versionCode = info.versionCode;
+ //Initialize the disk cache with a limit of 5 MB storage (fits ~830 images, taking Spotify as reference)
+ diskCache = DiskLruCache.open(cacheDir, versionCode, 1, 1000 * 1000 * 5);
+ } catch (PackageManager.NameNotFoundException e) {
+ throw new AssertionError(e);
+ } catch (IOException e) {
+ Log.e("KDE/Mpris/AlbumArtCache", "Could not open the album art disk cache!", e);
+ }
+ }
+
+ /**
+ * Registers a mpris plugin, such that it gets notified of fetched album art
+ * @param mpris The mpris plugin
+ */
+ public static void registerPlugin(MprisPlugin mpris) {
+ registeredPlugins.add(mpris);
+ }
+
+ /**
+ * Deregister a mpris plugin
+ * @param mpris The mpris plugin
+ */
+ public static void deregisterPlugin(MprisPlugin mpris) {
+ registeredPlugins.remove(mpris);
+ }
+
+ /**
+ * Get the album art for the given url. Currently only handles http(s) urls.
+ * If it's not in the cache, will initiate a request to fetch it.
+ *
+ * @param albumUrl The album art url
+ * @return A bitmap for the album art. Can be null if not (yet) found
+ */
+ public static Bitmap getAlbumArt(String albumUrl) {
+ //If the url is invalid, return "no album art"
+ if (albumUrl == null || albumUrl.isEmpty()) {
+ return null;
+ }
+
+ URL url;
+ try {
+ url = new URL(albumUrl);
+ } catch (MalformedURLException e) {
+ //Invalid url, so just return "no album art"
+ //Shouldn't happen (checked on receival of the url), but just to be sure
+ return null;
+ }
+
+ //We currently only support http(s) urls
+ if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https")) {
+ return null;
+ }
+
+ //First, check the in-memory cache
+ if (memoryCache.get(albumUrl) != null) {
+ MemoryCacheItem item = memoryCache.get(albumUrl);
+
+ //Do not retry failed fetches
+ if (item.failedFetch) {
+ return null;
+ } else {
+ return item.albumArt;
+ }
+ }
+
+ //If not found, check the disk cache
+ if (diskCache == null) {
+ Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!");
+ return null;
+ }
+ try {
+ DiskLruCache.Snapshot item = diskCache.get(urlToDiskCacheKey(albumUrl));
+ if (item != null) {
+ BitmapFactory.Options decodeOptions = new BitmapFactory.Options();
+ decodeOptions.inScaled = false;
+ decodeOptions.inDensity = 1;
+ decodeOptions.inTargetDensity = 1;
+ Bitmap result = BitmapFactory.decodeStream(item.getInputStream(0), null, decodeOptions);
+ item.close();
+ MemoryCacheItem memItem = new MemoryCacheItem();
+ if (result != null) {
+ memItem.failedFetch = false;
+ memItem.albumArt = result;
+ } else {
+ //Invalid bitmap, so remember it as a "failed fetch" and remove it from the disk cache
+ memItem.failedFetch = true;
+ memItem.albumArt = null;
+ diskCache.remove(urlToDiskCacheKey(albumUrl));
+ Log.d("KDE/Mpris/AlbumArtCache", "Invalid image: " + albumUrl);
+ }
+ memoryCache.put(albumUrl, memItem);
+ return result;
+ }
+ } catch (IOException e) {
+ return null;
+ }
+
+ /* If not found, we have not tried fetching it (recently), or a fetch is in-progress.
+ Either way, just add it to the fetch queue and starting fetching it if no fetch is running. */
+ fetchUrl(url);
+ return null;
+ }
+
+ /**
+ * Fetches an album art url and puts it in the cache
+ * @param url The url
+ */
+ private static void fetchUrl(URL url) {
+ //We need the disk cache for this
+ if (diskCache == null) {
+ Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!");
+ return;
+ }
+
+ //Only fetch an URL if we're not fetching it already
+ if (fetchUrlList.contains(url)) {
+ return;
+ }
+
+ fetchUrlList.add(url);
+ initiateFetch();
+ }
+
+ private static final class FetchURLTask extends AsyncTask {
+ private URL url;
+ private InputStream input;
+ private DiskLruCache.Editor cacheItem;
+ private OutputStream output;
+
+ /**
+ * Initialize an url fetch
+ *
+ * @param url The url being fetched
+ * @param payloadInput A payload input stream (if from the connected device). null if fetched from http(s)
+ * @param cacheItem The disk cache item to edit
+ * @throws IOException
+ */
+ FetchURLTask(URL url, InputStream payloadInput, DiskLruCache.Editor cacheItem) throws IOException {
+ this.url = url;
+ this.input = payloadInput;
+ this.cacheItem = cacheItem;
+ output = cacheItem.newOutputStream(0);
+ }
+
+ /**
+ * Opens the http(s) connection
+ * @return True if succeeded
+ * @throws IOException
+ */
+ private boolean openHttp() throws IOException {
+ //Default android behaviour does not follow https -> http urls, so do this manually
+ URL currentUrl = url;
+ HttpURLConnection connection;
+ for (int i = 0; i<5; ++i) {
+ connection = (HttpURLConnection) currentUrl.openConnection();
+ connection.setConnectTimeout(10000);
+ connection.setReadTimeout(10000);
+ connection.setInstanceFollowRedirects(false);
+
+ switch (connection.getResponseCode()) {
+ case HttpURLConnection.HTTP_MOVED_PERM:
+ case HttpURLConnection.HTTP_MOVED_TEMP:
+ String location = connection.getHeaderField("Location");
+ location = URLDecoder.decode(location, "UTF-8");
+ currentUrl = new URL(currentUrl, location); // Deal with relative URLs
+ //Again, only support http(s)
+ if (!currentUrl.getProtocol().equals("http") && !currentUrl.getProtocol().equals("https")) {
+ return false;
+ }
+ connection.disconnect();
+ continue;
+ }
+
+ //Found a non-redirecting connection, so do something with it
+ input = connection.getInputStream();
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ try {
+ //See if we need to open a http(s) connection here, or if we use a payload input stream
+ if (input == null) {
+ if (!openHttp()) {
+ return false;
+ }
+ }
+
+ byte[] buffer = new byte[4096];
+ int bytesRead;
+ while ((bytesRead = input.read(buffer)) != -1) {
+ output.write(buffer, 0, bytesRead);
+ }
+ output.flush();
+ output.close();
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Boolean success) {
+ try {
+ if (success) {
+ cacheItem.commit();
+ } else {
+ cacheItem.abort();
+ }
+ } catch (IOException e) {
+ success = false;
+ Log.e("KDE/Mpris/AlbumArtCache", "Problem with the disk cache", e);
+ }
+ if (success) {
+ //Now it's in the disk cache, the getAlbumArt() function should be able to read it
+
+ //So notify the mpris plugins of the fetched art
+ for (MprisPlugin mpris : registeredPlugins) {
+ mpris.fetchedAlbumArt(url.toString());
+ }
+ } else {
+ //Mark the fetch as failed in the memory cache
+ MemoryCacheItem cacheItem = new MemoryCacheItem();
+ cacheItem.failedFetch = true;
+ cacheItem.albumArt = null;
+ memoryCache.put(url.toString(), cacheItem);
+ }
+
+ //Remove the url from the to-fetch list
+ fetchUrlList.remove(url);
+ //Fetch the next url (if any)
+ --numFetching;
+ initiateFetch();
+ }
+ };
+
+ /**
+ * Does the actual fetching and makes sure only not too many fetches are running at the same time
+ */
+ private static void initiateFetch() {
+ if (numFetching >= 2) return;
+ if (fetchUrlList.isEmpty()) return;
+
+ ++numFetching;
+
+ //Fetch the last-requested url first, it will probably be needed first
+ URL url = fetchUrlList.get(fetchUrlList.size() - 1);
+ try {
+ DiskLruCache.Editor cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString()));
+ if (cacheItem == null) {
+ Log.e("KDE/Mpris/AlbumArtCache",
+ "Two disk cache edits happened at the same time, should be impossible!");
+ --numFetching;
+ return;
+ }
+
+ //Do the actual fetch in the background
+ new FetchURLTask(url, null, cacheItem).execute();
+ } catch (IOException e) {
+ Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e);
+ --numFetching;
+ }
+ }
+
+ /**
+ * The disk cache requires mostly alphanumeric characters, and at most 64 characters.
+ * So hash the url to get a valid key
+ * @param url The url
+ * @return A valid disk cache key
+ */
+ private static String urlToDiskCacheKey(String url) {
+ MessageDigest hasher;
+ try {
+ hasher = MessageDigest.getInstance("MD5");
+ } catch (NoSuchAlgorithmException e) {
+ //Should always be available
+ throw new AssertionError(e);
+ }
+
+ StringBuilder builder = new StringBuilder();
+ for (byte singleByte : hasher.digest(url.getBytes())) {
+ builder.append(String.format("%02x", singleByte));
+ }
+ return builder.toString();
+ }
+
+ /**
+ * Transfer an asked-for album art payload to the disk cache.
+ * @param albumUrl The url of the album art (should be a file:// url)
+ * @param payload The payload input stream
+ */
+ public static void payloadToDiskCache(String albumUrl, InputStream payload) {
+ //We need the disk cache for this
+ if (diskCache == null) {
+ Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!");
+ return;
+ }
+
+ URL url;
+ try {
+ url = new URL(albumUrl);
+ } catch (MalformedURLException e) {
+ //Shouldn't happen (checked on receival of the url), but just to be sure
+ return;
+ }
+
+ if (!"file".equals(url.getProtocol())) {
+ //Shouldn't happen (otherwise we wouldn't have asked for the payload), but just to be sure
+ return;
+ }
+
+ //Only fetch the URL if we're not fetching it already
+ if (fetchUrlList.contains(url)) {
+ return;
+ }
+
+ fetchUrlList.add(url);
+ ++numFetching;
+
+ try {
+ DiskLruCache.Editor cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString()));
+ if (cacheItem == null) {
+ Log.e("KDE/Mpris/AlbumArtCache",
+ "Two disk cache edits happened at the same time, should be impossible!");
+ --numFetching;
+ return;
+ }
+
+ //Do the actual fetch in the background
+ new FetchURLTask(url, payload, cacheItem).execute();
+ } catch (IOException e) {
+ Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e);
+ --numFetching;
+ }
+ }
+}
diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java
index 5e5e9609..e9036e34 100644
--- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java
+++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java
@@ -1,445 +1,458 @@
/*
* Copyright 2014 Albert Vaca Cintora
*
* 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 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* 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 .
*/
package org.kde.kdeconnect.Plugins.MprisPlugin;
import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
+import android.support.v4.graphics.drawable.DrawableCompat;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ImageButton;
+import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.Spinner;
import android.widget.TextView;
import org.kde.kdeconnect.Backends.BaseLink;
import org.kde.kdeconnect.Backends.BaseLinkProvider;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.NetworkPackage;
import org.kde.kdeconnect_tp.R;
import java.util.List;
public class MprisActivity extends AppCompatActivity {
private String deviceId;
private final Handler positionSeekUpdateHandler = new Handler();
private Runnable positionSeekUpdateRunnable = null;
private MprisPlugin.MprisPlayer targetPlayer = null;
private static String milisToProgress(long milis) {
int length = (int)(milis / 1000); //From milis to seconds
StringBuilder text = new StringBuilder();
int minutes = length / 60;
if (minutes > 60) {
int hours = minutes / 60;
minutes = minutes % 60;
text.append(hours).append(':');
if (minutes < 10) text.append('0');
}
text.append(minutes).append(':');
int seconds = (length % 60);
if(seconds < 10) text.append('0'); // needed to show length properly (eg 4:05 instead of 4:5)
text.append(seconds);
return text.toString();
}
protected void connectToPlugin(final String targetPlayerName) {
BackgroundService.RunCommand(this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
final Device device = service.getDevice(deviceId);
final MprisPlugin mpris = device.getPlugin(MprisPlugin.class);
if (mpris == null) {
Log.e("MprisActivity", "device has no mpris plugin!");
return;
}
targetPlayer = mpris.getPlayerStatus(targetPlayerName);
mpris.setPlayerStatusUpdatedHandler("activity", new Handler() {
@Override
public void handleMessage(Message msg) {
runOnUiThread(new Runnable() {
@Override
public void run() {
updatePlayerStatus(mpris);
}
});
}
});
mpris.setPlayerListUpdatedHandler("activity", new Handler() {
@Override
public void handleMessage(Message msg) {
final List playerList = mpris.getPlayerList();
final ArrayAdapter adapter = new ArrayAdapter<>(MprisActivity.this,
android.R.layout.simple_spinner_item,
playerList.toArray(new String[playerList.size()])
);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
runOnUiThread(new Runnable() {
@Override
public void run() {
Spinner spinner = (Spinner) findViewById(R.id.player_spinner);
//String prevPlayer = (String)spinner.getSelectedItem();
spinner.setAdapter(adapter);
if (playerList.isEmpty()) {
findViewById(R.id.no_players).setVisibility(View.VISIBLE);
spinner.setVisibility(View.GONE);
((TextView) findViewById(R.id.now_playing_textview)).setText("");
} else {
findViewById(R.id.no_players).setVisibility(View.GONE);
spinner.setVisibility(View.VISIBLE);
}
spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView> arg0, View arg1, int pos, long id) {
if (pos >= playerList.size()) return;
String player = playerList.get(pos);
if (targetPlayer != null && player.equals(targetPlayer.getPlayer())) {
return; //Player hasn't actually changed
}
targetPlayer = mpris.getPlayerStatus(player);
updatePlayerStatus(mpris);
}
@Override
public void onNothingSelected(AdapterView> arg0) {
targetPlayer = null;
}
});
if (targetPlayer != null) {
int targetIndex = adapter.getPosition(targetPlayer.getPlayer());
if (targetIndex >= 0) {
spinner.setSelection(targetIndex);
} else {
targetPlayer = null;
}
}
updatePlayerStatus(mpris);
}
});
}
});
}
});
}
private final BaseLinkProvider.ConnectionReceiver connectionReceiver = new BaseLinkProvider.ConnectionReceiver() {
@Override
public void onConnectionReceived(NetworkPackage identityPackage, BaseLink link) {
connectToPlugin(null);
}
@Override
public void onConnectionLost(BaseLink link) {
}
};
@Override
protected void onDestroy() {
super.onDestroy();
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
service.removeConnectionListener(connectionReceiver);
}
});
}
private void updatePlayerStatus(MprisPlugin mpris) {
MprisPlugin.MprisPlayer playerStatus = targetPlayer;
if (playerStatus == null) {
//No player with that name found, just display "empty" data
playerStatus = mpris.getEmptyPlayer();
}
String song = playerStatus.getCurrentSong();
TextView nowPlaying = (TextView) findViewById(R.id.now_playing_textview);
if (!nowPlaying.getText().toString().equals(song)) {
nowPlaying.setText(song);
}
+ Bitmap albumArt = playerStatus.getAlbumArt();
+ if (albumArt == null) {
+ Drawable placeholder_art = DrawableCompat.wrap(getResources().getDrawable(R.drawable.ic_album_art_placeholder));
+ DrawableCompat.setTint(placeholder_art, getResources().getColor(R.color.primary));
+ ((ImageView) findViewById(R.id.album_art)).setImageDrawable(placeholder_art);
+ } else {
+ ((ImageView) findViewById(R.id.album_art)).setImageBitmap(albumArt);
+ }
+
if (playerStatus.isSeekAllowed()) {
((TextView) findViewById(R.id.time_textview)).setText(milisToProgress(playerStatus.getLength()));
SeekBar positionSeek = (SeekBar)findViewById(R.id.positionSeek);
positionSeek.setMax((int)(playerStatus.getLength()));
positionSeek.setProgress((int)(playerStatus.getPosition()));
findViewById(R.id.progress_slider).setVisibility(View.VISIBLE);
} else {
findViewById(R.id.progress_slider).setVisibility(View.GONE);
}
int volume = playerStatus.getVolume();
((SeekBar) findViewById(R.id.volume_seek)).setProgress(volume);
boolean isPlaying = playerStatus.isPlaying();
if (isPlaying) {
((ImageButton) findViewById(R.id.play_button)).setImageResource(R.drawable.ic_pause_black);
findViewById(R.id.play_button).setVisibility(playerStatus.isPauseAllowed() ? View.VISIBLE : View.GONE);
} else {
((ImageButton) findViewById(R.id.play_button)).setImageResource(R.drawable.ic_play_black);
findViewById(R.id.play_button).setVisibility(playerStatus.isPlayAllowed() ? View.VISIBLE : View.GONE);
}
findViewById(R.id.volume_layout).setVisibility(playerStatus.isSetVolumeAllowed() ? View.VISIBLE : View.INVISIBLE);
findViewById(R.id.rew_button).setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE);
findViewById(R.id.ff_button).setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE);
findViewById(R.id.next_button).setVisibility(playerStatus.isGoNextAllowed() ? View.VISIBLE : View.GONE);
findViewById(R.id.prev_button).setVisibility(playerStatus.isGoPreviousAllowed() ? View.VISIBLE : View.GONE);
}
/**
* Change current volume with provided step.
*
* @param step step size volume change
*/
private void updateVolume(int step) {
if (targetPlayer == null) {
return;
}
final int currentVolume = targetPlayer.getVolume();
if(currentVolume < 100 || currentVolume > 0) {
int newVolume = currentVolume + step;
if(newVolume > 100) {
newVolume = 100;
} else if (newVolume <0 ) {
newVolume = 0;
}
targetPlayer.setVolume(newVolume);
}
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_VOLUME_UP:
updateVolume(5);
return true;
case KeyEvent.KEYCODE_VOLUME_DOWN:
updateVolume(-5);
return true;
default:
return super.onKeyDown(keyCode, event);
}
}
@Override
public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_VOLUME_UP:
return true;
case KeyEvent.KEYCODE_VOLUME_DOWN:
return true;
default:
return super.onKeyUp(keyCode, event);
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- setContentView(R.layout.mpris_control);
+ setContentView(R.layout.activity_mpris);
final String targetPlayerName = getIntent().getStringExtra("player");
getIntent().removeExtra("player");
deviceId = getIntent().getStringExtra("deviceId");
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String interval_time_str = prefs.getString(getString(R.string.mpris_time_key),
getString(R.string.mpris_time_default));
final int interval_time = Integer.parseInt(interval_time_str);
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
service.addConnectionListener(connectionReceiver);
}
});
connectToPlugin(targetPlayerName);
findViewById(R.id.play_button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
if (targetPlayer == null) return;
targetPlayer.playPause();
}
});
}
});
findViewById(R.id.prev_button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
if (targetPlayer == null) return;
targetPlayer.previous();
}
});
}
});
findViewById(R.id.rew_button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
if (targetPlayer == null) return;
targetPlayer.seek(interval_time * -1);
}
});
}
});
findViewById(R.id.ff_button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
if (targetPlayer == null) return;
targetPlayer.seek(interval_time);
}
});
}
});
findViewById(R.id.next_button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
if (targetPlayer == null) return;
targetPlayer.next();
}
});
}
});
((SeekBar)findViewById(R.id.volume_seek)).setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(final SeekBar seekBar) {
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
if (targetPlayer == null) return;
targetPlayer.setVolume(seekBar.getProgress());
}
});
}
});
positionSeekUpdateRunnable = new Runnable() {
@Override
public void run() {
final SeekBar positionSeek = (SeekBar)findViewById(R.id.positionSeek);
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
if (targetPlayer != null) {
positionSeek.setProgress((int) (targetPlayer.getPosition()));
}
positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable);
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 1000);
}
});
}
};
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200);
((SeekBar)findViewById(R.id.positionSeek)).setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean byUser) {
((TextView)findViewById(R.id.progress_textview)).setText(milisToProgress(progress));
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable);
}
@Override
public void onStopTrackingTouch(final SeekBar seekBar) {
BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
if (targetPlayer != null) {
targetPlayer.setPosition(seekBar.getProgress());
}
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200);
}
});
}
});
findViewById(R.id.now_playing_textview).setSelected(true);
}
@Override
protected void onStart() {
super.onStart();
BackgroundService.addGuiInUseCounter(this);
}
@Override
protected void onStop() {
super.onStop();
BackgroundService.removeGuiInUseCounter(this);
}
}
diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java
index aa5f0a50..cabc1637 100644
--- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java
+++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java
@@ -1,399 +1,445 @@
/*
* Copyright 2014 Albert Vaca Cintora
*
* 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 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* 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 .
*/
package org.kde.kdeconnect.Plugins.MprisPlugin;
import android.app.Activity;
import android.content.Intent;
+import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Message;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import org.kde.kdeconnect.NetworkPackage;
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect_tp.R;
+import java.net.MalformedURLException;
+import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
public class MprisPlugin extends Plugin {
public class MprisPlayer {
private String player = "";
private boolean playing = false;
private String currentSong = "";
private String title = "";
private String artist = "";
private String album = "";
+ private String albumArtUrl = "";
private int volume = 50;
private long length = -1;
private long lastPosition = 0;
private long lastPositionTime;
private boolean playAllowed = true;
private boolean pauseAllowed = true;
private boolean goNextAllowed = true;
private boolean goPreviousAllowed = true;
private boolean seekAllowed = true;
public MprisPlayer() {
lastPositionTime = System.currentTimeMillis();
}
public String getCurrentSong() {
return currentSong;
}
public String getTitle() {
return title;
}
public String getArtist() {
return artist;
}
public String getAlbum() {
return album;
}
public String getPlayer() {
return player;
}
private boolean isSpotify() {
return getPlayer().toLowerCase().equals("spotify");
}
public int getVolume() {
return volume;
}
public long getLength(){ return length; }
public boolean isPlaying() {
return playing;
}
public boolean isPlayAllowed() {
return playAllowed;
}
public boolean isPauseAllowed() {
return pauseAllowed;
}
public boolean isGoNextAllowed() {
return goNextAllowed;
}
public boolean isGoPreviousAllowed() {
return goPreviousAllowed;
}
public boolean isSeekAllowed() {
return seekAllowed && getLength() >= 0 && getPosition() >= 0 && !isSpotify();
}
+ public boolean hasAlbumArt() {
+ return !albumArtUrl.isEmpty();
+ }
+
+ /**
+ * Returns the album art (if available). Note that this can return null even if hasAlbumArt() returns true.
+ * @return The album art, or null if not available
+ */
+ public Bitmap getAlbumArt() {
+ return AlbumArtCache.getAlbumArt(albumArtUrl);
+ }
+
public boolean isSetVolumeAllowed() {
return !isSpotify();
}
public long getPosition(){
if(playing) {
return lastPosition + (System.currentTimeMillis() - lastPositionTime);
} else {
return lastPosition;
}
}
public void playPause() {
if (isPauseAllowed() || isPlayAllowed()) {
MprisPlugin.this.sendCommand(getPlayer(), "action", "PlayPause");
}
}
public void play() {
if (isPlayAllowed()) {
MprisPlugin.this.sendCommand(getPlayer(), "action", "Play");
}
}
public void pause() {
if (isPauseAllowed()) {
MprisPlugin.this.sendCommand(getPlayer(), "action", "Pause");
}
}
public void stop() {
MprisPlugin.this.sendCommand(getPlayer(), "action", "Stop");
}
public void previous() {
if (isGoPreviousAllowed()) {
MprisPlugin.this.sendCommand(getPlayer(), "action", "Previous");
}
}
public void next() {
if (isGoNextAllowed()) {
MprisPlugin.this.sendCommand(getPlayer(), "action", "Next");
}
}
public void setVolume(int volume) {
if (isSetVolumeAllowed()) {
MprisPlugin.this.sendCommand(getPlayer(), "setVolume", volume);
}
}
public void setPosition(int position) {
if (isSeekAllowed()) {
MprisPlugin.this.sendCommand(getPlayer(), "SetPosition", position);
lastPosition = position;
lastPositionTime = System.currentTimeMillis();
}
}
public void seek(int offset) {
if (isSeekAllowed()) {
MprisPlugin.this.sendCommand(getPlayer(), "Seek", offset);
}
}
}
public final static String PACKAGE_TYPE_MPRIS = "kdeconnect.mpris";
public final static String PACKAGE_TYPE_MPRIS_REQUEST = "kdeconnect.mpris.request";
private HashMap players = new HashMap<>();
private HashMap playerStatusUpdated = new HashMap<>();
private HashMap playerListUpdated = new HashMap<>();
@Override
public String getDisplayName() {
return context.getResources().getString(R.string.pref_plugin_mpris);
}
@Override
public String getDescription() {
return context.getResources().getString(R.string.pref_plugin_mpris_desc);
}
@Override
public Drawable getIcon() {
return ContextCompat.getDrawable(context, R.drawable.mpris_plugin_action);
}
@Override
public boolean hasSettings() {
return true;
}
@Override
public boolean onCreate() {
requestPlayerList();
//Always request the player list so the data is up-to-date
requestPlayerList();
+ AlbumArtCache.initializeDiskCache(context);
+ AlbumArtCache.registerPlugin(this);
+
return true;
}
@Override
public void onDestroy() {
players.clear();
+ AlbumArtCache.deregisterPlugin(this);
}
private void sendCommand(String player, String method, String value) {
NetworkPackage np = new NetworkPackage(PACKAGE_TYPE_MPRIS_REQUEST);
np.set("player", player);
np.set(method, value);
device.sendPackage(np);
}
private void sendCommand(String player, String method, int value) {
NetworkPackage np = new NetworkPackage(PACKAGE_TYPE_MPRIS_REQUEST);
np.set("player", player);
np.set(method, value);
device.sendPackage(np);
}
@Override
public boolean onPackageReceived(NetworkPackage np) {
if (np.has("nowPlaying") || np.has("volume") || np.has("isPlaying") || np.has("length") || np.has("pos")) {
MprisPlayer playerStatus = players.get(np.getString("player"));
if (playerStatus != null) {
playerStatus.currentSong = np.getString("nowPlaying", playerStatus.currentSong);
//Note: title, artist and album will not be available for all desktop clients
playerStatus.title = np.getString("title", playerStatus.title);
playerStatus.artist = np.getString("artist", playerStatus.artist);
playerStatus.album = np.getString("album", playerStatus.album);
playerStatus.volume = np.getInt("volume", playerStatus.volume);
playerStatus.length = np.getLong("length", playerStatus.length);
if(np.has("pos")){
playerStatus.lastPosition = np.getLong("pos", playerStatus.lastPosition);
playerStatus.lastPositionTime = System.currentTimeMillis();
}
playerStatus.playing = np.getBoolean("isPlaying", playerStatus.playing);
playerStatus.playAllowed = np.getBoolean("canPlay", playerStatus.playAllowed);
playerStatus.pauseAllowed = np.getBoolean("canPause", playerStatus.pauseAllowed);
playerStatus.goNextAllowed = np.getBoolean("canGoNext", playerStatus.goNextAllowed);
playerStatus.goPreviousAllowed = np.getBoolean("canGoPrevious", playerStatus.goPreviousAllowed);
playerStatus.seekAllowed = np.getBoolean("canSeek", playerStatus.seekAllowed);
+ String newAlbumArtUrlstring = np.getString("albumArtUrl", playerStatus.albumArtUrl);
+ try {
+ //Turn the url into canonical form (and check its validity)
+ URL newAlbumArtUrl = new URL(newAlbumArtUrlstring);
+ playerStatus.albumArtUrl = newAlbumArtUrlstring.toString();
+ } catch (MalformedURLException ignored) {}
for (String key : playerStatusUpdated.keySet()) {
try {
playerStatusUpdated.get(key).dispatchMessage(new Message());
} catch(Exception e) {
e.printStackTrace();
Log.e("MprisControl","Exception");
playerStatusUpdated.remove(key);
}
}
}
}
List newPlayerList = np.getStringList("playerList");
if (newPlayerList != null) {
boolean equals = true;
for (String newPlayer : newPlayerList) {
if (!players.containsKey(newPlayer)) {
equals = false;
MprisPlayer player = new MprisPlayer();
player.player = newPlayer;
players.put(newPlayer, player);
//Immediately ask for the data of this player
requestPlayerStatus(newPlayer);
}
}
Iterator> iter = players.entrySet().iterator();
while (iter.hasNext()) {
String oldPlayer = iter.next().getKey();
boolean found = false;
for (String newPlayer : newPlayerList) {
if (newPlayer.equals(oldPlayer)) {
found = true;
break;
}
}
if (!found) {
iter.remove();
equals = false;
}
}
if (!equals) {
for (String key : playerListUpdated.keySet()) {
try {
playerListUpdated.get(key).dispatchMessage(new Message());
} catch(Exception e) {
e.printStackTrace();
Log.e("MprisControl","Exception");
playerListUpdated.remove(key);
}
}
}
}
return true;
}
@Override
public String[] getSupportedPackageTypes() {
return new String[] {PACKAGE_TYPE_MPRIS};
}
@Override
public String[] getOutgoingPackageTypes() {
return new String[] {PACKAGE_TYPE_MPRIS_REQUEST};
}
public void setPlayerStatusUpdatedHandler(String id, Handler h) {
playerStatusUpdated.put(id, h);
h.dispatchMessage(new Message());
}
public void removePlayerStatusUpdatedHandler(String id) {
playerStatusUpdated.remove(id);
}
public void setPlayerListUpdatedHandler(String id, Handler h) {
playerListUpdated.put(id,h);
h.dispatchMessage(new Message());
}
public void removePlayerListUpdatedHandler(String id) {
playerListUpdated.remove(id);
}
public List getPlayerList() {
List playerlist = new ArrayList<>(players.keySet());
Collections.sort(playerlist);
return playerlist;
}
public MprisPlayer getPlayerStatus(String player) {
return players.get(player);
}
public MprisPlayer getEmptyPlayer() {
return new MprisPlayer();
}
private void requestPlayerList() {
NetworkPackage np = new NetworkPackage(PACKAGE_TYPE_MPRIS_REQUEST);
np.set("requestPlayerList",true);
device.sendPackage(np);
}
private void requestPlayerStatus(String player) {
NetworkPackage np = new NetworkPackage(PACKAGE_TYPE_MPRIS_REQUEST);
np.set("player", player);
np.set("requestNowPlaying",true);
np.set("requestVolume",true);
device.sendPackage(np);
}
@Override
public boolean hasMainActivity() {
return true;
}
@Override
public void startMainActivity(Activity parentActivity) {
Intent intent = new Intent(parentActivity, MprisActivity.class);
intent.putExtra("deviceId", device.getDeviceId());
parentActivity.startActivity(intent);
}
@Override
public String getActionName() {
return context.getString(R.string.open_mpris_controls);
}
+ public void fetchedAlbumArt(String url) {
+ boolean doEmitUpdate = false;
+ for (MprisPlayer player : players.values()) {
+ if (url.equals(player.albumArtUrl)) {
+ doEmitUpdate = true;
+ }
+ }
+
+ if (doEmitUpdate) {
+ for (String key : playerStatusUpdated.keySet()) {
+ try {
+ playerStatusUpdated.get(key).dispatchMessage(new Message());
+ } catch(Exception e) {
+ e.printStackTrace();
+ Log.e("MprisControl","Exception");
+ playerStatusUpdated.remove(key);
+ }
+ }
+ }
+ }
}