mirror of
https://github.com/clangen/musikcube.git
synced 2025-01-30 06:32:36 +00:00
Added musikdroid (promoted from an the musikcube-websockets external
repo).
This commit is contained in:
parent
3512e14a2e
commit
a367a0471e
8
src/musikdroid/.gitignore
vendored
Normal file
8
src/musikdroid/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
1
src/musikdroid/app/.gitignore
vendored
Normal file
1
src/musikdroid/app/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
46
src/musikdroid/app/build.gradle
Normal file
46
src/musikdroid/app/build.gradle
Normal file
@ -0,0 +1,46 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'me.tatarka.retrolambda'
|
||||
|
||||
android {
|
||||
compileSdkVersion 25
|
||||
buildToolsVersion "23.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "io.casey.musikcube.remote"
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 25
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compile fileTree(dir: 'libs', include: ['*.jar'])
|
||||
|
||||
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
})
|
||||
|
||||
compile 'com.neovisionaries:nv-websocket-client:1.31'
|
||||
compile 'com.squareup.okhttp3:okhttp:3.6.0'
|
||||
compile 'com.github.bumptech.glide:glide:3.7.0'
|
||||
|
||||
compile 'com.android.support:appcompat-v7:25.1.1'
|
||||
compile 'com.android.support:recyclerview-v7:25.1.1'
|
||||
compile 'com.android.support:design:25.1.1'
|
||||
|
||||
testCompile 'junit:junit:4.12'
|
||||
}
|
23
src/musikdroid/app/proguard-rules.pro
vendored
Normal file
23
src/musikdroid/app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /Users/clangen/src/android-sdk/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
-keep public class * implements com.bumptech.glide.module.GlideModule
|
||||
-keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** {
|
||||
**[] $VALUES;
|
||||
public *;
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.Context;
|
||||
import android.support.test.InstrumentationRegistry;
|
||||
import android.support.test.runner.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Instrumentation test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||
|
||||
assertEquals("io.casey.musikcube.remote", appContext.getPackageName());
|
||||
}
|
||||
}
|
57
src/musikdroid/app/src/main/AndroidManifest.xml
Normal file
57
src/musikdroid/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="io.casey.musikcube.remote" >
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme" >
|
||||
|
||||
<activity android:name=".MainActivity"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".AlbumBrowseActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity android:name=".SettingsActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize|stateHidden" />
|
||||
|
||||
<activity android:name=".PlayQueueActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity android:name=".TrackListActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.searchable"
|
||||
android:resource="@xml/searchable" />
|
||||
|
||||
</activity>
|
||||
|
||||
<activity android:name=".CategoryBrowseActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
@ -0,0 +1,198 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.util.LruCache;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
public final class AlbumArtModel {
|
||||
/* http://www.last.fm/group/Last.fm+Web+Services/forum/21604/_/522900 -- it's ok to
|
||||
put our key in the code */
|
||||
private static final String LAST_FM_ALBUM_INFO =
|
||||
"http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=" +
|
||||
"502c69bd3f9946e8e0beee4fcb28c4cd&artist=%s&album=%s&format=json";
|
||||
|
||||
private static OkHttpClient OK_HTTP;
|
||||
|
||||
private static LruCache<Integer, String> URL_CACHE = new LruCache<>(500);
|
||||
|
||||
static {
|
||||
OK_HTTP = new OkHttpClient.Builder()
|
||||
.addInterceptor((Interceptor.Chain chain) -> {
|
||||
Request request = chain.request();
|
||||
|
||||
int count = 0;
|
||||
while (count++ < 3) {
|
||||
try {
|
||||
Response response = chain.proceed(request);
|
||||
if (response.isSuccessful()) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
catch (SocketTimeoutException ex) {
|
||||
/* om nom nom */
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("retries exhausted");
|
||||
})
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String track, artist, album, url;
|
||||
private AlbumArtCallback callback;
|
||||
private boolean fetching;
|
||||
private boolean noart;
|
||||
private long loadTime = 0;
|
||||
private int id;
|
||||
|
||||
public AlbumArtModel() {
|
||||
this("", "", "", null);
|
||||
}
|
||||
|
||||
public AlbumArtModel(String track, String artist, String album, AlbumArtCallback callback) {
|
||||
this.track = track;
|
||||
this.artist = artist;
|
||||
this.album = album;
|
||||
this.callback = callback != null ? callback : (info, url) -> { };
|
||||
this.id = (artist + album).hashCode();
|
||||
|
||||
synchronized (this) {
|
||||
this.url = URL_CACHE.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
this.callback = (info, url) -> { };
|
||||
}
|
||||
|
||||
public boolean is(String artist, String album) {
|
||||
return (this.artist != null && this.artist.equalsIgnoreCase(artist) &&
|
||||
this.album != null && this.album.equalsIgnoreCase(album));
|
||||
}
|
||||
|
||||
public String getTrack() {
|
||||
return track;
|
||||
}
|
||||
|
||||
public interface AlbumArtCallback {
|
||||
void onFinished(final AlbumArtModel info, final String url);
|
||||
}
|
||||
|
||||
public synchronized String getUrl() {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
public synchronized long getLoadTimeMillis() {
|
||||
return this.loadTime;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public synchronized void fetch() {
|
||||
if (this.fetching || this.noart) {
|
||||
return;
|
||||
}
|
||||
if (!Strings.empty(this.url)) {
|
||||
callback.onFinished(this, this.url);
|
||||
}
|
||||
else if (Strings.notEmpty(this.artist) && Strings.notEmpty(this.album)) {
|
||||
String requestUrl;
|
||||
|
||||
try {
|
||||
final String sanitizedAlbum = removeBadAlbumSuffixes(album);
|
||||
|
||||
requestUrl = String.format(
|
||||
LAST_FM_ALBUM_INFO,
|
||||
URLEncoder.encode(artist, "UTF-8"),
|
||||
URLEncoder.encode(sanitizedAlbum, "UTF-8"));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
|
||||
final long start = System.currentTimeMillis();
|
||||
this.fetching = true;
|
||||
final Request request = new Request.Builder().url(requestUrl).build();
|
||||
|
||||
OK_HTTP.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
fetching = false;
|
||||
callback.onFinished(AlbumArtModel.this, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
synchronized (AlbumArtModel.this) {
|
||||
try {
|
||||
final JSONObject json = new JSONObject(response.body().string());
|
||||
final JSONArray images = json.getJSONObject("album").getJSONArray("image");
|
||||
for (int i = images.length() - 1; i >= 0; i--) {
|
||||
final JSONObject image = images.getJSONObject(i);
|
||||
final String size = image.optString("size", "");
|
||||
if (size != null && size.length() > 0) {
|
||||
final String resolvedUrl = image.optString("#text", "");
|
||||
if (resolvedUrl != null && resolvedUrl.length() > 0) {
|
||||
synchronized (AlbumArtModel.this) {
|
||||
URL_CACHE.put(id, resolvedUrl);
|
||||
}
|
||||
|
||||
AlbumArtModel.this.url = resolvedUrl;
|
||||
loadTime = System.currentTimeMillis() - start;
|
||||
callback.onFinished(AlbumArtModel.this, resolvedUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (JSONException ex) {
|
||||
}
|
||||
|
||||
noart = true; /* got a response, but it was invalid. we won't try again */
|
||||
fetching = false;
|
||||
}
|
||||
|
||||
callback.onFinished(AlbumArtModel.this, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
callback.onFinished(this, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern[] BAD_PATTERNS = {
|
||||
Pattern.compile("(?i)" + Pattern.quote("(") + "disc \\d*" + Pattern.quote(")") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[") + "disc \\d*" + Pattern.quote("]") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("(+") + "video" + Pattern.quote(")") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[+") + "video" + Pattern.quote("]") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("(") + "explicit" + Pattern.quote(")") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[") + "explicit" + Pattern.quote("]") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[+") + "digital booklet" + Pattern.quote("]") + "$")
|
||||
};
|
||||
|
||||
private static String removeBadAlbumSuffixes(final String album) {
|
||||
String result = album;
|
||||
for (Pattern pattern : BAD_PATTERNS) {
|
||||
result = pattern.matcher(result).replaceAll("");
|
||||
}
|
||||
return result.trim();
|
||||
}
|
||||
}
|
@ -0,0 +1,182 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import static io.casey.musikcube.remote.Messages.Key;
|
||||
|
||||
public class AlbumBrowseActivity extends WebSocketActivityBase implements Filterable {
|
||||
private static final String EXTRA_CATEGORY_NAME = "extra_category_name";
|
||||
private static final String EXTRA_CATEGORY_ID = "extra_category_id";
|
||||
|
||||
public static Intent getStartIntent(final Context context) {
|
||||
return new Intent(context, AlbumBrowseActivity.class);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context, final String categoryName, long categoryId) {
|
||||
return new Intent(context, AlbumBrowseActivity.class)
|
||||
.putExtra(EXTRA_CATEGORY_NAME, categoryName)
|
||||
.putExtra(EXTRA_CATEGORY_ID, categoryId);
|
||||
}
|
||||
|
||||
|
||||
private WebSocketService wss;
|
||||
private Adapter adapter;
|
||||
private TransportFragment transport;
|
||||
private String categoryName;
|
||||
private long categoryId;
|
||||
private String filter = "";
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
this.categoryName = getIntent().getStringExtra(EXTRA_CATEGORY_NAME);
|
||||
this.categoryId = getIntent().getLongExtra(EXTRA_CATEGORY_ID, categoryId);
|
||||
|
||||
setContentView(R.layout.recycler_view_activity);
|
||||
|
||||
this.wss = getWebSocketService();
|
||||
this.adapter = new Adapter();
|
||||
|
||||
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
|
||||
|
||||
Views.setupDefaultRecyclerView(this, recyclerView, adapter);
|
||||
|
||||
transport = Views.addTransportFragment(this,
|
||||
(TransportFragment fragment) -> adapter.notifyDataSetChanged());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
Views.initSearchMenu(this, menu, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFilter(String filter) {
|
||||
this.filter = filter;
|
||||
requery();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return socketClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (resultCode == Navigation.ResponseCode.PLAYBACK_STARTED) {
|
||||
setResult(Navigation.ResponseCode.PLAYBACK_STARTED);
|
||||
finish();
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
}
|
||||
|
||||
private void requery() {
|
||||
final SocketMessage message =
|
||||
SocketMessage.Builder
|
||||
.request(Messages.Request.QueryAlbums)
|
||||
.addOption(Messages.Key.CATEGORY, categoryName)
|
||||
.addOption(Messages.Key.CATEGORY_ID, categoryId)
|
||||
.addOption(Key.FILTER, filter)
|
||||
.build();
|
||||
|
||||
wss.send(message, socketClient, (SocketMessage response) ->
|
||||
adapter.setModel(response.getJsonArrayOption(Messages.Key.DATA)));
|
||||
}
|
||||
|
||||
private WebSocketService.Client socketClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
requery();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
private View.OnClickListener onItemClickListener = (View view) -> {
|
||||
long id = (Long) view.getTag();
|
||||
|
||||
final Intent intent = TrackListActivity.getStartIntent(
|
||||
AlbumBrowseActivity.this, Messages.Category.ALBUM, id);
|
||||
|
||||
startActivityForResult(intent, Navigation.RequestCode.ALBUM_TRACKS_ACTIVITY);
|
||||
};
|
||||
|
||||
private class ViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView title;
|
||||
private final TextView subtitle;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
title = (TextView) itemView.findViewById(R.id.title);
|
||||
subtitle = (TextView) itemView.findViewById(R.id.subtitle);
|
||||
}
|
||||
|
||||
void bind(JSONObject entry) {
|
||||
long playingId = transport.getModel().getTrackValueLong(Key.ALBUM_ID, -1);
|
||||
long entryId = entry.optLong(Key.ID);
|
||||
|
||||
int titleColor = R.color.theme_foreground;
|
||||
int subtitleColor = R.color.theme_disabled_foreground;
|
||||
|
||||
if (playingId != -1 && entryId == playingId) {
|
||||
titleColor = R.color.theme_green;
|
||||
subtitleColor = R.color.theme_yellow;
|
||||
}
|
||||
|
||||
title.setText(entry.optString(Key.TITLE, "-"));
|
||||
title.setTextColor(getResources().getColor(titleColor));
|
||||
|
||||
subtitle.setText(entry.optString(Key.ALBUM_ARTIST, "-"));
|
||||
subtitle.setTextColor(getResources().getColor(subtitleColor));
|
||||
|
||||
itemView.setTag(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter extends RecyclerView.Adapter<ViewHolder> {
|
||||
private JSONArray model;
|
||||
|
||||
void setModel(JSONArray model) {
|
||||
this.model = model;
|
||||
this.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||
final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
final View view = inflater.inflate(R.layout.simple_list_item, parent, false);
|
||||
view.setOnClickListener(onItemClickListener);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
holder.bind(model.optJSONObject(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (model == null) ? 0 : model.length();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,186 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class CategoryBrowseActivity extends WebSocketActivityBase implements Filterable {
|
||||
private static final String EXTRA_CATEGORY = "extra_category";
|
||||
|
||||
private static final Map<String, String> CATEGORY_NAME_TO_ID = new HashMap<>();
|
||||
|
||||
static {
|
||||
CATEGORY_NAME_TO_ID.put(Messages.Key.ALBUM_ARTIST, Messages.Key.ALBUM_ARTIST_ID);
|
||||
CATEGORY_NAME_TO_ID.put(Messages.Key.GENRE, Messages.Key.GENRE_ID);
|
||||
CATEGORY_NAME_TO_ID.put(Messages.Key.ARTIST, Messages.Key.ARTIST_ID);
|
||||
CATEGORY_NAME_TO_ID.put(Messages.Key.ALBUM, Messages.Key.ALBUM_ID);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context, final String category) {
|
||||
return new Intent(context, CategoryBrowseActivity.class)
|
||||
.putExtra(EXTRA_CATEGORY, category);
|
||||
}
|
||||
|
||||
private String category;
|
||||
private Adapter adapter;
|
||||
private String filter;
|
||||
private TransportFragment transport;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
this.category = getIntent().getStringExtra(EXTRA_CATEGORY);
|
||||
this.adapter = new Adapter();
|
||||
|
||||
setContentView(R.layout.recycler_view_activity);
|
||||
|
||||
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
|
||||
|
||||
Views.setupDefaultRecyclerView(this, recyclerView, this.adapter);
|
||||
|
||||
transport = Views.addTransportFragment(this,
|
||||
(TransportFragment fragment) -> adapter.notifyDataSetChanged());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
Views.initSearchMenu(this, menu, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (resultCode == Navigation.ResponseCode.PLAYBACK_STARTED) {
|
||||
setResult(Navigation.ResponseCode.PLAYBACK_STARTED);
|
||||
finish();
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFilter(String filter) {
|
||||
this.filter = filter;
|
||||
requery();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return socketClient;
|
||||
}
|
||||
|
||||
private void requery() {
|
||||
final SocketMessage request = SocketMessage.Builder
|
||||
.request(Messages.Request.QueryCategory)
|
||||
.addOption(Messages.Key.CATEGORY, category)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build();
|
||||
|
||||
getWebSocketService().send(request, this.socketClient, (SocketMessage response) -> {
|
||||
JSONArray data = response.getJsonArrayOption(Messages.Key.DATA);
|
||||
if (data != null && data.length() > 0) {
|
||||
adapter.setModel(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private WebSocketService.Client socketClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
requery();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
private View.OnClickListener onItemClickListener = (View view) -> {
|
||||
final long categoryId = (Long) view.getTag();
|
||||
final Intent intent = AlbumBrowseActivity.getStartIntent(this, category, categoryId);
|
||||
startActivityForResult(intent, Navigation.RequestCode.ALBUM_BROWSE_ACTIVITY);
|
||||
};
|
||||
|
||||
private View.OnLongClickListener onItemLongClickListener = (View view) -> {
|
||||
final long categoryId = (Long) view.getTag();
|
||||
final Intent intent = TrackListActivity.getStartIntent(this, category, categoryId);
|
||||
startActivityForResult(intent, Navigation.RequestCode.CATEGORY_TRACKS_ACTIVITY);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private class ViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView title;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
title = (TextView) itemView.findViewById(R.id.title);
|
||||
itemView.findViewById(R.id.subtitle).setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
void bind(JSONObject entry) {
|
||||
final long entryId = entry.optLong(Messages.Key.ID);
|
||||
long playingId = -1;
|
||||
|
||||
final String idKey = CATEGORY_NAME_TO_ID.get(category);
|
||||
if (idKey != null && idKey.length() > 0) {
|
||||
playingId = transport.getModel().getTrackValueLong(idKey, -1);
|
||||
}
|
||||
|
||||
int titleColor = R.color.theme_foreground;
|
||||
if (playingId != -1 && entryId == playingId) {
|
||||
titleColor = R.color.theme_green;
|
||||
}
|
||||
|
||||
title.setText(entry.optString(Messages.Key.VALUE, "-"));
|
||||
title.setTextColor(getResources().getColor(titleColor));
|
||||
|
||||
itemView.setTag(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter extends RecyclerView.Adapter<ViewHolder> {
|
||||
private JSONArray model;
|
||||
|
||||
void setModel(JSONArray model) {
|
||||
this.model = model;
|
||||
this.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||
final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
final View view = inflater.inflate(R.layout.simple_list_item, parent, false);
|
||||
view.setOnClickListener(onItemClickListener);
|
||||
view.setOnLongClickListener(onItemLongClickListener);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
holder.bind(model.optJSONObject(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (model == null) ? 0 : model.length();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
public interface Filterable {
|
||||
void setFilter(final String filter);
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
public class LongPressTextView extends TextView {
|
||||
private static final int TICK_START_DELAY = 700;
|
||||
private static final int MINIMUM_TICK_DELAY = 100;
|
||||
private static final int TICK_DELTA = 100;
|
||||
private static final int FIRST_TICK_DELAY = 200;
|
||||
|
||||
public interface OnTickListener {
|
||||
void onTick(final View view);
|
||||
}
|
||||
|
||||
private int tickDelay = 0;
|
||||
private int ticksFired = 0;
|
||||
private boolean isDown;
|
||||
private Handler handler = new Handler();
|
||||
private OnTickListener onTickListener;
|
||||
private View.OnClickListener onClickListener;
|
||||
|
||||
public LongPressTextView(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public LongPressTextView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public LongPressTextView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
public void setOnTickListener(OnTickListener onTickListener) {
|
||||
this.onTickListener = onTickListener;
|
||||
this.setClickable(onTickListener != null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnClickListener(OnClickListener l) {
|
||||
this.onClickListener = l;
|
||||
}
|
||||
|
||||
private void init() {
|
||||
super.setOnClickListener(onClickProxy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
if (event.getAction() == MotionEvent.ACTION_DOWN) {
|
||||
isDown = true;
|
||||
ticksFired = 0;
|
||||
tickDelay = TICK_START_DELAY;
|
||||
handler.removeCallbacks(tickRunnable);
|
||||
handler.postDelayed(tickRunnable, FIRST_TICK_DELAY);
|
||||
}
|
||||
else if (event.getAction() == MotionEvent.ACTION_UP) {
|
||||
handler.removeCallbacks(tickRunnable);
|
||||
ticksFired = 0;
|
||||
isDown = false;
|
||||
}
|
||||
|
||||
return super.onTouchEvent(event);
|
||||
}
|
||||
|
||||
private Runnable tickRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (isDown) {
|
||||
if (onTickListener != null) {
|
||||
onTickListener.onTick(LongPressTextView.this);
|
||||
}
|
||||
|
||||
tickDelay = Math.max(MINIMUM_TICK_DELAY, tickDelay - TICK_DELTA);
|
||||
handler.postDelayed(tickRunnable, tickDelay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private View.OnClickListener onClickProxy = new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
if (onTickListener == null && onClickListener != null) {
|
||||
onClickListener.onClick(view);
|
||||
}
|
||||
else if (onTickListener != null && ticksFired == 0) {
|
||||
onTickListener.onTick(view);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,543 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextPaint;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewPropertyAnimator;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.CompoundButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.resource.drawable.GlideDrawable;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class MainActivity extends WebSocketActivityBase {
|
||||
private static Map<TransportModel.RepeatMode, Integer> REPEAT_TO_STRING_ID;
|
||||
private static final int ARTIFICIAL_ARTWORK_DELAY_MILLIS = 0;
|
||||
|
||||
private WebSocketService wss = null;
|
||||
private TransportModel model = new TransportModel();
|
||||
|
||||
private SharedPreferences prefs;
|
||||
private TextView title, artist, album, playPause, volume;
|
||||
private TextView titleWithArt, artistAndAlbumWithArt, volumeWithArt;
|
||||
private TextView notPlayingOrDisconnected;
|
||||
private View connected;
|
||||
private CheckBox shuffleCb, muteCb, repeatCb;
|
||||
private ImageView albumArtImageView;
|
||||
private View mainTrackMetadataWithAlbumArt, mainTrackMetadataNoAlbumArt;
|
||||
private Handler handler = new Handler();
|
||||
private ViewPropertyAnimator metadataAnim1, metadataAnim2;
|
||||
|
||||
/* ugh, artwork related */
|
||||
private enum DisplayMode { Artwork, NoArtwork, Stopped }
|
||||
private AlbumArtModel albumArtModel = new AlbumArtModel();
|
||||
|
||||
static {
|
||||
REPEAT_TO_STRING_ID = new HashMap<>();
|
||||
REPEAT_TO_STRING_ID.put(TransportModel.RepeatMode.None, R.string.button_repeat_off);
|
||||
REPEAT_TO_STRING_ID.put(TransportModel.RepeatMode.List, R.string.button_repeat_list);
|
||||
REPEAT_TO_STRING_ID.put(TransportModel.RepeatMode.Track, R.string.button_repeat_track);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context) {
|
||||
return new Intent(context, MainActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
this.prefs = this.getSharedPreferences("prefs", Context.MODE_PRIVATE);
|
||||
this.wss = getWebSocketService();
|
||||
|
||||
setContentView(R.layout.activity_main);
|
||||
bindEventListeners();
|
||||
|
||||
if (!this.wss.hasValidConnection()) {
|
||||
startActivity(SettingsActivity.getStartIntent(this));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
unbindCheckboxEventListeners();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
bindCheckBoxEventListeners();
|
||||
rebindUi();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(Bundle savedInstanceState) {
|
||||
super.onRestoreInstanceState(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.main_menu, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
if (item.getItemId() == R.id.action_settings) {
|
||||
startActivity(SettingsActivity.getStartIntent(this));
|
||||
return true;
|
||||
}
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return this.serviceClient;
|
||||
}
|
||||
|
||||
private void bindCheckBoxEventListeners() {
|
||||
this.shuffleCb.setOnCheckedChangeListener(shuffleListener);
|
||||
this.muteCb.setOnCheckedChangeListener(muteListener);
|
||||
this.repeatCb.setOnCheckedChangeListener(repeatListener);
|
||||
}
|
||||
|
||||
/* onRestoreInstanceState() calls setChecked(), which has the side effect of
|
||||
running these callbacks. this screws up state, especially for the repeat checkbox */
|
||||
private void unbindCheckboxEventListeners() {
|
||||
this.shuffleCb.setOnCheckedChangeListener(null);
|
||||
this.muteCb.setOnCheckedChangeListener(null);
|
||||
this.repeatCb.setOnCheckedChangeListener(null);
|
||||
}
|
||||
|
||||
private void bindEventListeners() {
|
||||
this.title = (TextView) findViewById(R.id.track_title);
|
||||
this.artist = (TextView) findViewById(R.id.track_artist);
|
||||
this.album = (TextView) findViewById(R.id.track_album);
|
||||
this.volume = (TextView) findViewById(R.id.volume);
|
||||
|
||||
this.titleWithArt = (TextView) findViewById(R.id.with_art_track_title);
|
||||
this.artistAndAlbumWithArt = (TextView) findViewById(R.id.with_art_artist_and_album);
|
||||
this.volumeWithArt = (TextView) findViewById(R.id.with_art_volume);
|
||||
|
||||
this.playPause = (TextView) findViewById(R.id.button_play_pause);
|
||||
this.shuffleCb = (CheckBox) findViewById(R.id.check_shuffle);
|
||||
this.muteCb = (CheckBox) findViewById(R.id.check_mute);
|
||||
this.repeatCb = (CheckBox) findViewById(R.id.check_repeat);
|
||||
this.mainTrackMetadataWithAlbumArt = findViewById(R.id.main_track_metadata_with_art);
|
||||
this.mainTrackMetadataNoAlbumArt = findViewById(R.id.main_track_metadata_without_art);
|
||||
this.notPlayingOrDisconnected = (TextView) findViewById(R.id.main_not_playing);
|
||||
this.albumArtImageView = (ImageView) findViewById(R.id.album_art);
|
||||
this.connected = findViewById(R.id.connected);
|
||||
|
||||
/* these will get faded in as appropriate */
|
||||
this.mainTrackMetadataNoAlbumArt.setAlpha(0.0f);
|
||||
this.mainTrackMetadataWithAlbumArt.setAlpha(0.0f);
|
||||
|
||||
findViewById(R.id.button_prev).setOnClickListener((View view) ->
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.Previous).build()));
|
||||
|
||||
final LongPressTextView seekBack = (LongPressTextView) findViewById(R.id.button_seek_back);
|
||||
seekBack.setOnTickListener((View view) ->
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SeekRelative)
|
||||
.addOption(Messages.Key.DELTA, -5.0f).build()));
|
||||
|
||||
findViewById(R.id.button_play_pause).setOnClickListener((View view) -> {
|
||||
if (model.getPlaybackState() == TransportModel.PlaybackState.Stopped) {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.PlayAllTracks).build());
|
||||
}
|
||||
else {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.PauseOrResume).build());
|
||||
}
|
||||
});
|
||||
|
||||
findViewById(R.id.button_next).setOnClickListener((View view) -> {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.Next).build());
|
||||
});
|
||||
|
||||
final LongPressTextView seekForward = (LongPressTextView) findViewById(R.id.button_seek_forward);
|
||||
seekForward.setOnTickListener((View view) ->
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SeekRelative)
|
||||
.addOption(Messages.Key.DELTA, 5.0f).build()));
|
||||
|
||||
final LongPressTextView volumeUp = (LongPressTextView) findViewById(R.id.button_vol_up);
|
||||
volumeUp.setOnTickListener((View view) -> {
|
||||
double volume = Math.min(1.0f, model.getVolume() + 0.05);
|
||||
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SetVolume)
|
||||
.addOption(TransportModel.Key.VOLUME, volume)
|
||||
.build());
|
||||
});
|
||||
|
||||
final LongPressTextView volumeDown = (LongPressTextView) findViewById(R.id.button_vol_down);
|
||||
volumeDown.setOnTickListener((View view) -> {
|
||||
double volume = Math.max(0.0f, model.getVolume() - 0.05);
|
||||
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SetVolume)
|
||||
.addOption(TransportModel.Key.VOLUME, volume)
|
||||
.build());
|
||||
});
|
||||
|
||||
findViewById(R.id.button_artists).setOnClickListener((View view) -> {
|
||||
startActivity(CategoryBrowseActivity.getStartIntent(this, Messages.Category.ALBUM_ARTIST));
|
||||
});
|
||||
|
||||
findViewById(R.id.button_tracks).setOnClickListener((View view) -> {
|
||||
startActivity(TrackListActivity.getStartIntent(MainActivity.this));
|
||||
});
|
||||
|
||||
findViewById(R.id.button_albums).setOnClickListener((View view) -> {
|
||||
startActivity(AlbumBrowseActivity.getStartIntent(MainActivity.this));
|
||||
});
|
||||
|
||||
findViewById(R.id.button_play_queue).setOnClickListener((view) -> navigateToPlayQueue());
|
||||
|
||||
findViewById(R.id.metadata_container).setOnClickListener((view) -> {
|
||||
if (model.getQueueCount() > 0) {
|
||||
navigateToPlayQueue();
|
||||
}
|
||||
});
|
||||
|
||||
this.album.setOnClickListener((view) -> navigateToCurrentAlbum());
|
||||
this.artist.setOnClickListener((view) -> navigateToCurrentArtist());
|
||||
}
|
||||
|
||||
private void rebindAlbumArtistWithArtTextView() {
|
||||
final String artist = model.getTrackValueString(TransportModel.Key.ARTIST, "");
|
||||
final String album = model.getTrackValueString(TransportModel.Key.ALBUM, "");
|
||||
|
||||
final ForegroundColorSpan albumColor =
|
||||
new ForegroundColorSpan(getResources().getColor(R.color.theme_orange));
|
||||
|
||||
final ForegroundColorSpan artistColor =
|
||||
new ForegroundColorSpan(getResources().getColor(R.color.theme_yellow));
|
||||
|
||||
final SpannableStringBuilder builder =
|
||||
new SpannableStringBuilder().append(album).append(" - ").append(artist);
|
||||
|
||||
final ClickableSpan albumClickable = new ClickableSpan() {
|
||||
@Override
|
||||
public void onClick(View widget) {
|
||||
navigateToCurrentAlbum();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDrawState(TextPaint ds) {
|
||||
}
|
||||
};
|
||||
|
||||
final ClickableSpan artistClickable = new ClickableSpan() {
|
||||
@Override
|
||||
public void onClick(View widget) {
|
||||
navigateToCurrentArtist();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDrawState(TextPaint ds) {
|
||||
}
|
||||
};
|
||||
|
||||
int artistOffset = album.length() + 3;
|
||||
|
||||
builder.setSpan(albumColor, 0, album.length(), 0);
|
||||
builder.setSpan(albumClickable, 0, album.length(), 0);
|
||||
builder.setSpan(artistColor, artistOffset, artistOffset + artist.length(), 0);
|
||||
builder.setSpan(artistClickable, artistOffset, artistOffset + artist.length(), 0);
|
||||
|
||||
this.artistAndAlbumWithArt.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
this.artistAndAlbumWithArt.setHighlightColor(Color.TRANSPARENT);
|
||||
this.artistAndAlbumWithArt.setText(builder);
|
||||
}
|
||||
|
||||
private void rebindUi() {
|
||||
/* state management for UI stuff is starting to get out of hand. we should
|
||||
refactor things pretty soon before they're completely out of control */
|
||||
|
||||
final WebSocketService.State state = wss.getState();
|
||||
|
||||
final boolean connected = state == WebSocketService.State.Connected;
|
||||
|
||||
final boolean playing = (model.getPlaybackState() == TransportModel.PlaybackState.Playing);
|
||||
this.playPause.setText(playing ? R.string.button_pause : R.string.button_play);
|
||||
|
||||
final boolean stopped = (model.getPlaybackState() == TransportModel.PlaybackState.Stopped);
|
||||
notPlayingOrDisconnected.setVisibility(stopped ? View.VISIBLE : View.GONE);
|
||||
|
||||
final boolean stateIsValidForArtwork = !stopped && connected;
|
||||
|
||||
this.connected.setVisibility((connected && stopped) ? View.VISIBLE : View.GONE);
|
||||
|
||||
/* setup our state as if we have no album art -- because we don't know if we have any
|
||||
yet! the album art load process (if enabled) will ensure the correct metadata block
|
||||
is displayed in the correct location */
|
||||
if (!stateIsValidForArtwork) {
|
||||
setMetadataDisplayMode(DisplayMode.Stopped);
|
||||
notPlayingOrDisconnected.setText(connected ? R.string.transport_not_playing : R.string.status_disconnected);
|
||||
notPlayingOrDisconnected.setVisibility(View.VISIBLE);
|
||||
}
|
||||
else {
|
||||
notPlayingOrDisconnected.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
final String artist = model.getTrackValueString(TransportModel.Key.ARTIST, "");
|
||||
final String album = model.getTrackValueString(TransportModel.Key.ALBUM, "");
|
||||
final String title = model.getTrackValueString(TransportModel.Key.TITLE, "");
|
||||
final String volume = getString(R.string.status_volume, Math.round(model.getVolume() * 100));
|
||||
|
||||
this.title.setText(title);
|
||||
this.artist.setText(artist);
|
||||
this.album.setText(album);
|
||||
this.volume.setText(volume);
|
||||
|
||||
this.rebindAlbumArtistWithArtTextView();
|
||||
this.titleWithArt.setText(title);
|
||||
this.volumeWithArt.setText(volume);
|
||||
|
||||
final TransportModel.RepeatMode repeatMode = model.getRepeatMode();
|
||||
final boolean repeatChecked = (repeatMode != TransportModel.RepeatMode.None);
|
||||
repeatCb.setText(REPEAT_TO_STRING_ID.get(repeatMode));
|
||||
Views.setCheckWithoutEvent(repeatCb, repeatChecked, this.repeatListener);
|
||||
|
||||
Views.setCheckWithoutEvent(this.shuffleCb, model.isShuffled(), this.shuffleListener);
|
||||
Views.setCheckWithoutEvent(this.muteCb, model.isMuted(), this.muteListener);
|
||||
|
||||
boolean albumArtEnabledInSettings = this.prefs.getBoolean("album_art_enabled", true);
|
||||
|
||||
if (stateIsValidForArtwork) {
|
||||
if (!albumArtEnabledInSettings) {
|
||||
this.albumArtModel = new AlbumArtModel();
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork);
|
||||
}
|
||||
else {
|
||||
if (!this.albumArtModel.is(artist, album)) {
|
||||
this.albumArtModel.destroy();
|
||||
this.albumArtModel = new AlbumArtModel(title, artist, album, albumArtRetrieved);
|
||||
}
|
||||
|
||||
updateAlbumArt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setMetadataDisplayMode(DisplayMode mode) {
|
||||
if (metadataAnim1 != null) {
|
||||
metadataAnim1.cancel();
|
||||
metadataAnim2.cancel();
|
||||
}
|
||||
|
||||
if (mode == DisplayMode.Artwork) {
|
||||
metadataAnim1 = Views.animateAlpha(mainTrackMetadataWithAlbumArt, 1.0f);
|
||||
metadataAnim2 = Views.animateAlpha(mainTrackMetadataNoAlbumArt, 0.0f);
|
||||
}
|
||||
else {
|
||||
albumArtImageView.setImageDrawable(null);
|
||||
|
||||
/* oh god why. hack to make volume % disappear. */
|
||||
float noArtAlpha = (mode == DisplayMode.Stopped) ? 0.0f : 1.0f;
|
||||
metadataAnim2 = Views.animateAlpha(mainTrackMetadataNoAlbumArt, noArtAlpha);
|
||||
metadataAnim1 = Views.animateAlpha(mainTrackMetadataWithAlbumArt, 0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
private void preloadNextImage() {
|
||||
final SocketMessage request = SocketMessage.Builder
|
||||
.request(Messages.Request.QueryPlayQueueTracks)
|
||||
.addOption(Messages.Key.OFFSET, this.model.getQueuePosition() + 1)
|
||||
.addOption(Messages.Key.LIMIT, 1)
|
||||
.build();
|
||||
|
||||
this.wss.send(request, this.getWebSocketServiceClient(), (response) -> {
|
||||
final JSONArray data = response.getJsonArrayOption(Messages.Key.DATA, new JSONArray());
|
||||
if (data.length() > 0) {
|
||||
JSONObject track = data.optJSONObject(0);
|
||||
final String artist = track.optString(TransportModel.Key.ARTIST, "");
|
||||
final String album = track.optString(TransportModel.Key.ALBUM, "");
|
||||
|
||||
if (!albumArtModel.is(artist, album)) {
|
||||
new AlbumArtModel("", artist, album, (info, url) -> {
|
||||
int width = albumArtImageView.getWidth();
|
||||
int height = albumArtImageView.getHeight();
|
||||
Glide.with(MainActivity.this).load(url).downloadOnly(width, height);
|
||||
}).fetch();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateAlbumArt() {
|
||||
if (model.getPlaybackState() == TransportModel.PlaybackState.Stopped) {
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork);
|
||||
}
|
||||
|
||||
final String url = albumArtModel.getUrl();
|
||||
|
||||
if (Strings.empty(url)) {
|
||||
albumArtModel.fetch();
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork);
|
||||
}
|
||||
else {
|
||||
final int loadId = albumArtModel.getId();
|
||||
|
||||
Glide.with(this)
|
||||
.load(url)
|
||||
.listener(new RequestListener<String, GlideDrawable>() {
|
||||
@Override
|
||||
public boolean onException(Exception e,
|
||||
String model,
|
||||
Target<GlideDrawable> target,
|
||||
boolean isFirstResource)
|
||||
{
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onResourceReady(GlideDrawable resource,
|
||||
String model,
|
||||
Target<GlideDrawable> target,
|
||||
boolean isFromMemoryCache,
|
||||
boolean isFirstResource)
|
||||
{
|
||||
preloadNextImage();
|
||||
|
||||
/* if the loadId doesn't match the current id, then the image was
|
||||
loaded for a different song. throw it away. */
|
||||
if (albumArtModel.getId() != loadId) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
setMetadataDisplayMode(DisplayMode.Artwork);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})
|
||||
.into(albumArtImageView);
|
||||
}
|
||||
}
|
||||
|
||||
private void navigateToCurrentArtist() {
|
||||
final long artistId = model.getTrackValueLong(TransportModel.Key.ARTIST_ID, -1);
|
||||
if (artistId != -1) {
|
||||
startActivity(AlbumBrowseActivity.getStartIntent(
|
||||
MainActivity.this, Messages.Category.ARTIST, artistId));
|
||||
}
|
||||
}
|
||||
|
||||
private void navigateToCurrentAlbum() {
|
||||
final long albumId = model.getTrackValueLong(TransportModel.Key.ALBUM_ID, -1);
|
||||
if (albumId != -1) {
|
||||
startActivity(TrackListActivity.getStartIntent(
|
||||
MainActivity.this, Messages.Category.ALBUM, albumId));
|
||||
}
|
||||
}
|
||||
|
||||
private void navigateToPlayQueue() {
|
||||
startActivity(PlayQueueActivity.getStartIntent(MainActivity.this, model.getQueuePosition()));
|
||||
}
|
||||
|
||||
private AlbumArtModel.AlbumArtCallback albumArtRetrieved = (model, url) -> {
|
||||
long delay = Math.max(0, ARTIFICIAL_ARTWORK_DELAY_MILLIS - model.getLoadTimeMillis());
|
||||
|
||||
handler.postDelayed(() -> {
|
||||
if (model == albumArtModel) {
|
||||
updateAlbumArt();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
private CheckBox.OnCheckedChangeListener muteListener =
|
||||
(CompoundButton compoundButton, boolean b) -> {
|
||||
if (b != model.isMuted()) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.ToggleMute).build());
|
||||
}
|
||||
};
|
||||
|
||||
private CheckBox.OnCheckedChangeListener shuffleListener =
|
||||
(CompoundButton compoundButton, boolean b) -> {
|
||||
if (b != model.isShuffled()) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.ToggleShuffle).build());
|
||||
}
|
||||
};
|
||||
|
||||
final CheckBox.OnCheckedChangeListener repeatListener = new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
|
||||
final TransportModel.RepeatMode currentMode = model.getRepeatMode();
|
||||
|
||||
TransportModel.RepeatMode newMode = TransportModel.RepeatMode.None;
|
||||
|
||||
if (currentMode == TransportModel.RepeatMode.None) {
|
||||
newMode = TransportModel.RepeatMode.List;
|
||||
}
|
||||
else if (currentMode == TransportModel.RepeatMode.List) {
|
||||
newMode = TransportModel.RepeatMode.Track;
|
||||
}
|
||||
|
||||
final boolean checked = (newMode != TransportModel.RepeatMode.None);
|
||||
compoundButton.setText(REPEAT_TO_STRING_ID.get(newMode));
|
||||
Views.setCheckWithoutEvent(repeatCb, checked, this);
|
||||
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.ToggleRepeat)
|
||||
.build());
|
||||
}
|
||||
};
|
||||
|
||||
private WebSocketService.Client serviceClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.GetPlaybackOverview.toString()).build());
|
||||
}
|
||||
else if (newState == WebSocketService.State.Disconnected) {
|
||||
model.reset();
|
||||
albumArtModel = new AlbumArtModel();
|
||||
updateAlbumArt();
|
||||
}
|
||||
|
||||
rebindUi();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
if (model.canHandle(message)) {
|
||||
if (model.update(message)) {
|
||||
rebindUi();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
public class Messages {
|
||||
public enum Request {
|
||||
Ping("ping"),
|
||||
GetPlaybackOverview("get_playback_overview"),
|
||||
PauseOrResume("pause_or_resume"),
|
||||
Stop("stop"),
|
||||
Previous("previous"),
|
||||
Next("next"),
|
||||
PlayAtIndex("play_at_index"),
|
||||
ToggleShuffle("toggle_shuffle"),
|
||||
ToggleRepeat("toggle_repeat"),
|
||||
ToggleMute("toggle_mute"),
|
||||
SetVolume("set_volume"),
|
||||
SeekTo("seek_to"),
|
||||
SeekRelative("seek_relative"),
|
||||
PlayAllTracks("play_all_tracks"),
|
||||
PlayTracks("play_tracks"),
|
||||
PlayTracksByCategory("play_tracks_by_category"),
|
||||
QueryTracks("query_tracks"),
|
||||
QueryTracksByCategory("query_tracks_by_category"),
|
||||
QueryCategory("query_category"),
|
||||
QueryAlbums("query_albums"),
|
||||
QueryPlayQueueTracks("query_play_queue_tracks");
|
||||
|
||||
private String rawValue;
|
||||
|
||||
Request(String rawValue) {
|
||||
this.rawValue = rawValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
public static Request from(String rawValue) {
|
||||
for (final Request value : Request.values()) {
|
||||
if (value.toString().equals(rawValue)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Broadcast {
|
||||
PlaybackOverviewChanged("playback_overview_changed"),
|
||||
PlayQueueChanged("play_queue_changed");
|
||||
|
||||
private String rawValue;
|
||||
|
||||
Broadcast(String rawValue) {
|
||||
this.rawValue = rawValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
public boolean is(String rawValue) {
|
||||
return this.rawValue.equals(rawValue);
|
||||
}
|
||||
|
||||
public static Broadcast from(String rawValue) {
|
||||
for (final Broadcast value : Broadcast.values()) {
|
||||
if (value.toString().equals(rawValue)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Key {
|
||||
String CATEGORY = "category";
|
||||
String CATEGORY_ID = "category_id";
|
||||
String DATA = "data";
|
||||
String ID = "id";
|
||||
String IDS = "ids";
|
||||
String TITLE = "title";
|
||||
String ALBUM = "album";
|
||||
String ALBUM_ID = "album_id";
|
||||
String ALBUM_ARTIST = "album_artist";
|
||||
String ALBUM_ARTIST_ID = "album_artist_id";
|
||||
String GENRE = "genre";
|
||||
String GENRE_ID = "visual_genre_id";
|
||||
String ARTIST = "artist";
|
||||
String ARTIST_ID = "visual_artist_id";
|
||||
String COUNT = "count";
|
||||
String COUNT_ONLY = "count_only";
|
||||
String OFFSET = "offset";
|
||||
String LIMIT = "limit";
|
||||
String INDEX = "index";
|
||||
String DELTA = "delta";
|
||||
String VALUE = "value";
|
||||
String FILTER = "filter";
|
||||
String RELATIVE = "relative";
|
||||
}
|
||||
|
||||
public interface Category {
|
||||
String ALBUM = "album";
|
||||
String ARTIST = "artist";
|
||||
String ALBUM_ARTIST = "album_artist";
|
||||
String GENRE = "genre";
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
public final class Navigation {
|
||||
private Navigation() {
|
||||
}
|
||||
|
||||
public interface RequestCode {
|
||||
int ALBUM_TRACKS_ACTIVITY = 10;
|
||||
int ALBUM_BROWSE_ACTIVITY = 11;
|
||||
int CATEGORY_TRACKS_ACTIVITY = 12;
|
||||
}
|
||||
|
||||
public interface ResponseCode {
|
||||
int PLAYBACK_STARTED = Activity.RESULT_FIRST_USER + 0xbeef;
|
||||
}
|
||||
}
|
@ -0,0 +1,180 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class PlayQueueActivity extends WebSocketActivityBase {
|
||||
private static String EXTRA_PLAYING_INDEX = "extra_playing_index";
|
||||
|
||||
public static Intent getStartIntent(final Context context, int playingIndex) {
|
||||
return new Intent(context, PlayQueueActivity.class)
|
||||
.putExtra(EXTRA_PLAYING_INDEX, playingIndex);
|
||||
}
|
||||
|
||||
private WebSocketService wss;
|
||||
private TrackListScrollCache<JSONObject> tracks;
|
||||
private TransportModel transportModel = new TransportModel();
|
||||
private Adapter adapter;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
this.wss = getWebSocketService();
|
||||
|
||||
setContentView(R.layout.recycler_view_activity);
|
||||
|
||||
this.adapter = new Adapter();
|
||||
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
|
||||
|
||||
Views.setupDefaultRecyclerView(this, recyclerView, adapter);
|
||||
|
||||
this.tracks = new TrackListScrollCache<>(
|
||||
recyclerView, adapter, this.wss, this.queryFactory, (JSONObject obj) -> obj);
|
||||
|
||||
this.tracks.setInitialPosition(
|
||||
getIntent().getIntExtra(EXTRA_PLAYING_INDEX, -1));
|
||||
|
||||
Views.addTransportFragment(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
this.tracks.destroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return webSocketClient;
|
||||
}
|
||||
|
||||
private void updatePlaybackModel(final SocketMessage message) {
|
||||
transportModel.update(message);
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
private final TrackListScrollCache.QueryFactory queryFactory
|
||||
= new TrackListScrollCache.QueryFactory() {
|
||||
@Override
|
||||
public SocketMessage getRequeryMessage() {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryPlayQueueTracks)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SocketMessage getPageAroundMessage(int offset, int limit) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryPlayQueueTracks)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.build();
|
||||
}
|
||||
};
|
||||
|
||||
private final View.OnClickListener onItemClickListener = new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (v.getTag() instanceof Integer) {
|
||||
final int index = (Integer) v.getTag();
|
||||
|
||||
wss.send(SocketMessage
|
||||
.Builder.request(Messages.Request.PlayAtIndex)
|
||||
.addOption(Messages.Key.INDEX, index)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final WebSocketService.Client webSocketClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
final SocketMessage overview = SocketMessage.Builder
|
||||
.request(Messages.Request.GetPlaybackOverview).build();
|
||||
|
||||
wss.send(overview, this, (SocketMessage response) -> updatePlaybackModel(response));
|
||||
|
||||
tracks.requery();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage broadcast) {
|
||||
if (Messages.Broadcast.PlaybackOverviewChanged.is(broadcast.getName())) {
|
||||
updatePlaybackModel(broadcast);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private class ViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView title;
|
||||
private final TextView subtitle;
|
||||
private final TextView trackNum;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
title = (TextView) itemView.findViewById(R.id.title);
|
||||
subtitle = (TextView) itemView.findViewById(R.id.subtitle);
|
||||
trackNum = (TextView) itemView.findViewById(R.id.track_num);
|
||||
}
|
||||
|
||||
void bind(JSONObject entry, int position) {
|
||||
trackNum.setText(String.valueOf(position + 1));
|
||||
itemView.setTag(position);
|
||||
int titleColor = R.color.theme_foreground;
|
||||
int subtitleColor = R.color.theme_disabled_foreground;
|
||||
|
||||
if (entry == null) {
|
||||
title.setText("-");
|
||||
subtitle.setText("-");
|
||||
}
|
||||
else {
|
||||
long playingId = transportModel.getTrackValueLong(Messages.Key.ID, -1);
|
||||
long entryId = entry.optLong(Messages.Key.ID, -1);
|
||||
|
||||
if (entryId != -1 && playingId == entryId) {
|
||||
titleColor = R.color.theme_green;
|
||||
subtitleColor = R.color.theme_yellow;
|
||||
}
|
||||
|
||||
title.setText(entry.optString(Messages.Key.TITLE, "-"));
|
||||
subtitle.setText(entry.optString(Messages.Key.ALBUM_ARTIST, "-"));
|
||||
}
|
||||
|
||||
title.setTextColor(getResources().getColor(titleColor));
|
||||
subtitle.setTextColor(getResources().getColor(subtitleColor));
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter extends RecyclerView.Adapter<ViewHolder> {
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||
final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
final View view = inflater.inflate(R.layout.play_queue_row, parent, false);
|
||||
view.setOnClickListener(onItemClickListener);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
holder.bind(tracks.getTrack(position), position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (tracks == null) ? 0 : tracks.getCount();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.os.Looper;
|
||||
|
||||
public class Preconditions {
|
||||
public static void throwIfNotOnMainThread() {
|
||||
if (Thread.currentThread() != Looper.getMainLooper().getThread()) {
|
||||
throw new IllegalStateException("not on main thread");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.view.View;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.EditText;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class SettingsActivity extends AppCompatActivity {
|
||||
private EditText addressText, portText;
|
||||
private CheckBox albumArtCheckbox;
|
||||
private SharedPreferences prefs;
|
||||
|
||||
public static Intent getStartIntent(final Context context) {
|
||||
return new Intent(context, SettingsActivity.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
prefs = this.getSharedPreferences("prefs", MODE_PRIVATE);
|
||||
setContentView(R.layout.activity_settings);
|
||||
bindEventListeners();
|
||||
rebindUi();
|
||||
}
|
||||
|
||||
private void rebindUi() {
|
||||
Views.setTextAndMoveCursorToEnd(this.addressText, prefs.getString("address", "192.168.1.100"));
|
||||
Views.setTextAndMoveCursorToEnd(this.portText, String.format(Locale.ENGLISH, "%d", prefs.getInt("port", 9002)));
|
||||
}
|
||||
|
||||
private void bindEventListeners() {
|
||||
this.addressText = (EditText) this.findViewById(R.id.address);
|
||||
this.portText = (EditText) this.findViewById(R.id.port);
|
||||
this.albumArtCheckbox = (CheckBox) findViewById(R.id.album_art_checkbox);
|
||||
|
||||
this.albumArtCheckbox.setChecked(this.prefs.getBoolean("album_art_enabled", true));
|
||||
|
||||
this.findViewById(R.id.button_connect).setOnClickListener((View v) -> {
|
||||
final String addr = addressText.getText().toString();
|
||||
final String port = portText.getText().toString();
|
||||
|
||||
prefs.edit()
|
||||
.putString("address", addr)
|
||||
.putInt("port", (port.length() > 0) ? Integer.valueOf(port) : 0)
|
||||
.putBoolean("album_art_enabled", albumArtCheckbox.isChecked())
|
||||
.apply();
|
||||
|
||||
WebSocketService.getInstance(this).disconnect();
|
||||
WebSocketService.getInstance(this).reconnect();
|
||||
|
||||
finish();
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,274 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class SocketMessage {
|
||||
private static final String TAG = SocketMessage.class.getCanonicalName();
|
||||
|
||||
public enum Type {
|
||||
Request("request"),
|
||||
Response("response"),
|
||||
Broadcast("broadcast");
|
||||
|
||||
private String rawType;
|
||||
|
||||
Type(String rawType) {
|
||||
this.rawType = rawType;
|
||||
}
|
||||
|
||||
public String getRawType() {
|
||||
return rawType;
|
||||
}
|
||||
|
||||
public static Type fromString(final String str) {
|
||||
if (Request.rawType.equals(str)) {
|
||||
return Request;
|
||||
}
|
||||
else if (Response.rawType.equals(str)) {
|
||||
return Response;
|
||||
}
|
||||
else if (Broadcast.rawType.equals(str)) {
|
||||
return Broadcast;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("str");
|
||||
}
|
||||
}
|
||||
|
||||
private String name;
|
||||
private String id;
|
||||
private Type type;
|
||||
private JSONObject options;
|
||||
|
||||
public static SocketMessage create(String string) {
|
||||
try {
|
||||
JSONObject object = new JSONObject(string);
|
||||
final String name = object.getString("name");
|
||||
final Type type = Type.fromString(object.getString("type"));
|
||||
final String id = object.getString("id");
|
||||
final JSONObject options = object.optJSONObject("options");
|
||||
return new SocketMessage(name, id, type, options);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Log.e(TAG, ex.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SocketMessage(String name, String id, Type type, JSONObject options) {
|
||||
if (name == null || name.length() == 0 || id == null || id.length() == 0 || type == null) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
this.name = name;
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.options = (options == null) ? new JSONObject() : options;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public <T> T getOption(final String key) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return (T) options.get(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getStringOption(final String key) {
|
||||
return getStringOption(key, "");
|
||||
}
|
||||
|
||||
public String getStringOption(final String key, final String defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getString(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public int getIntOption(final String key) {
|
||||
return getIntOption(key, 0);
|
||||
}
|
||||
|
||||
public int getIntOption(final String key, final int defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getInt(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
|
||||
public double getDoubleOption(final String key) {
|
||||
return getDoubleOption(key, 0.0);
|
||||
}
|
||||
|
||||
public double getDoubleOption(final String key, final double defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getDouble(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public boolean getBooleanOption(final String key) {
|
||||
return getBooleanOption(key, false);
|
||||
}
|
||||
|
||||
public boolean getBooleanOption(final String key, final boolean defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getBoolean(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public JSONObject getJsonObjectOption(final String key) {
|
||||
return getJsonObjectOption(key, null);
|
||||
}
|
||||
|
||||
public JSONObject getJsonObjectOption(final String key, final JSONObject defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getJSONObject(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public JSONArray getJsonArrayOption(final String key) {
|
||||
return getJsonArrayOption(key, null);
|
||||
}
|
||||
|
||||
public JSONArray getJsonArrayOption(final String key, final JSONArray defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getJSONArray(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
try {
|
||||
final JSONObject json = new JSONObject();
|
||||
json.put("name", name);
|
||||
json.put("id", id);
|
||||
json.put("type", type.getRawType());
|
||||
json.put("options", options);
|
||||
return json.toString();
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
throw new RuntimeException("unable to generate output JSON!");
|
||||
}
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private static AtomicInteger nextId = new AtomicInteger();
|
||||
|
||||
private String name;
|
||||
private Type type;
|
||||
private String id;
|
||||
private JSONObject options = new JSONObject();
|
||||
|
||||
private Builder() {
|
||||
|
||||
}
|
||||
|
||||
private static String newId() {
|
||||
return String.format(Locale.ENGLISH, "musikcube-android-client-%d", nextId.incrementAndGet());
|
||||
}
|
||||
|
||||
public static Builder broadcast(String name) {
|
||||
final Builder builder = new Builder();
|
||||
builder.name = name;
|
||||
builder.id = newId();
|
||||
builder.type = Type.Response;
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static Builder respondTo(final SocketMessage message) {
|
||||
final Builder builder = new Builder();
|
||||
builder.name = message.getName();
|
||||
builder.id = message.getId();
|
||||
builder.type = Type.Response;
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static Builder request(final String name) {
|
||||
final Builder builder = new Builder();
|
||||
builder.name = name;
|
||||
builder.id = newId();
|
||||
builder.type = Type.Request;
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static Builder request(final Messages.Request name) {
|
||||
final Builder builder = new Builder();
|
||||
builder.name = name.toString();
|
||||
builder.id = newId();
|
||||
builder.type = Type.Request;
|
||||
return builder;
|
||||
}
|
||||
|
||||
public Builder addOption(final String key, final Object value) {
|
||||
try {
|
||||
options.put(key, value);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
throw new RuntimeException("addOption failed??");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public SocketMessage build() {
|
||||
return new SocketMessage(name, id, type, options);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
public class Strings {
|
||||
public static boolean empty(final String s) {
|
||||
return (s == null || s.length() == 0);
|
||||
}
|
||||
|
||||
public static boolean notEmpty(final String s) {
|
||||
return (s != null && s.length() > 0);
|
||||
}
|
||||
}
|
@ -0,0 +1,242 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import static io.casey.musikcube.remote.TrackListScrollCache.QueryFactory;
|
||||
|
||||
public class TrackListActivity extends WebSocketActivityBase implements Filterable {
|
||||
private static String EXTRA_CATEGORY_TYPE = "extra_category_type";
|
||||
private static String EXTRA_SELECTED_ID = "extra_selected_id";
|
||||
|
||||
public static Intent getStartIntent(final Context context, final String type, final long id) {
|
||||
return new Intent(context, TrackListActivity.class)
|
||||
.putExtra(EXTRA_CATEGORY_TYPE, type)
|
||||
.putExtra(EXTRA_SELECTED_ID, id);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context) {
|
||||
return new Intent(context, TrackListActivity.class);
|
||||
}
|
||||
|
||||
private TrackListScrollCache<JSONObject> tracks;
|
||||
private TransportFragment transport;
|
||||
private String categoryType;
|
||||
private long categoryId;
|
||||
private String filter = "";
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setContentView(R.layout.recycler_view_activity);
|
||||
|
||||
categoryType = getIntent().getStringExtra(EXTRA_CATEGORY_TYPE);
|
||||
categoryId = getIntent().getLongExtra(EXTRA_SELECTED_ID, 0);
|
||||
|
||||
final QueryFactory queryFactory =
|
||||
createCategoryQueryFactory(categoryType, categoryId);
|
||||
|
||||
final Adapter adapter = new Adapter();
|
||||
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
|
||||
Views.setupDefaultRecyclerView(this, recyclerView, adapter);
|
||||
|
||||
tracks = new TrackListScrollCache<>(
|
||||
recyclerView, adapter, getWebSocketService(), queryFactory, (JSONObject track) -> track);
|
||||
|
||||
transport = Views.addTransportFragment(this,
|
||||
(TransportFragment fragment) -> adapter.notifyDataSetChanged());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
Views.initSearchMenu(this, menu, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
this.tracks.destroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return socketServiceClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFilter(String filter) {
|
||||
this.filter = filter;
|
||||
tracks.requery();
|
||||
}
|
||||
|
||||
private WebSocketService.Client socketServiceClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
tracks.requery();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
}
|
||||
};
|
||||
|
||||
private View.OnClickListener onItemClickListener = (View view) -> {
|
||||
int index = (Integer) view.getTag();
|
||||
SocketMessage request;
|
||||
|
||||
if (isValidCategory(categoryType, categoryId)) {
|
||||
request = SocketMessage.Builder
|
||||
.request(Messages.Request.PlayTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, categoryType)
|
||||
.addOption(Messages.Key.ID, categoryId)
|
||||
.addOption(Messages.Key.INDEX, index)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build();
|
||||
}
|
||||
else {
|
||||
request = SocketMessage.Builder
|
||||
.request(Messages.Request.PlayAllTracks)
|
||||
.addOption(Messages.Key.INDEX, index)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build();
|
||||
}
|
||||
|
||||
getWebSocketService().send(request, socketServiceClient, (SocketMessage response) -> {
|
||||
setResult(Navigation.ResponseCode.PLAYBACK_STARTED);
|
||||
finish();
|
||||
});
|
||||
};
|
||||
|
||||
private class ViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView title;
|
||||
private final TextView subtitle;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
title = (TextView) itemView.findViewById(R.id.title);
|
||||
subtitle = (TextView) itemView.findViewById(R.id.subtitle);
|
||||
}
|
||||
|
||||
void bind(JSONObject entry, int position) {
|
||||
itemView.setTag(position);
|
||||
|
||||
/* TODO: this colorizing logic is copied from PlayQueueActivity. can we generalize
|
||||
it cleanly somehow? is it worth it? */
|
||||
int titleColor = R.color.theme_foreground;
|
||||
int subtitleColor = R.color.theme_disabled_foreground;
|
||||
|
||||
if (entry != null) {
|
||||
long playingId = transport.getModel().getTrackValueLong(Messages.Key.ID, -1);
|
||||
long entryId = entry.optLong(Messages.Key.ID, -1);
|
||||
|
||||
if (entryId != -1 && playingId == entryId) {
|
||||
titleColor = R.color.theme_green;
|
||||
subtitleColor = R.color.theme_yellow;
|
||||
}
|
||||
|
||||
title.setText(entry.optString(Messages.Key.TITLE, "-"));
|
||||
subtitle.setText(entry.optString(Messages.Key.ALBUM_ARTIST, "-"));
|
||||
}
|
||||
else {
|
||||
title.setText("-");
|
||||
subtitle.setText("-");
|
||||
}
|
||||
|
||||
title.setTextColor(getResources().getColor(titleColor));
|
||||
subtitle.setTextColor(getResources().getColor(subtitleColor));
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter extends RecyclerView.Adapter<ViewHolder> {
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||
final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
final View view = inflater.inflate(R.layout.simple_list_item, parent, false);
|
||||
view.setOnClickListener(onItemClickListener);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
holder.bind(tracks.getTrack(position), position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (tracks == null) ? 0 : tracks.getCount();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidCategory(final String categoryType, long categoryId) {
|
||||
return categoryType != null && categoryType.length() > 0 && categoryId != -1;
|
||||
}
|
||||
|
||||
private QueryFactory createCategoryQueryFactory(
|
||||
final String categoryType, long categoryId) {
|
||||
|
||||
if (isValidCategory(categoryType, categoryId)) {
|
||||
/* tracks for a specified category (album, artists, genres, etc */
|
||||
return new QueryFactory() {
|
||||
@Override
|
||||
public SocketMessage getRequeryMessage() {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, categoryType)
|
||||
.addOption(Messages.Key.ID, categoryId)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SocketMessage getPageAroundMessage(int offset, int limit) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, categoryType)
|
||||
.addOption(Messages.Key.ID, categoryId)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
else {
|
||||
/* all tracks */
|
||||
return new QueryFactory() {
|
||||
@Override
|
||||
public SocketMessage getRequeryMessage() {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SocketMessage getPageAroundMessage(int offset, int limit) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,175 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class TrackListScrollCache<TrackType> {
|
||||
private static final int MAX_SIZE = 150;
|
||||
public static final int DEFAULT_WINDOW_SIZE = 75 ;
|
||||
|
||||
private int count = 0;
|
||||
private RecyclerView recyclerView;
|
||||
private RecyclerView.Adapter<?> adapter;
|
||||
private WebSocketService wss;
|
||||
private Mapper<TrackType> mapper;
|
||||
private QueryFactory queryFactory;
|
||||
private int scrollState = RecyclerView.SCROLL_STATE_IDLE;
|
||||
private int queryOffset = -1, queryLimit = -1;
|
||||
private int initialPosition = -1;
|
||||
|
||||
private static class CacheEntry<TrackType> {
|
||||
TrackType value;
|
||||
boolean dirty;
|
||||
}
|
||||
|
||||
private Map<Integer, CacheEntry<TrackType>> cache = new LinkedHashMap<Integer, CacheEntry<TrackType>>() {
|
||||
protected boolean removeEldestEntry(Map.Entry<Integer, CacheEntry<TrackType>> eldest) {
|
||||
return size() >= MAX_SIZE;
|
||||
}
|
||||
};
|
||||
|
||||
public interface Mapper<TrackType> {
|
||||
TrackType map(final JSONObject track);
|
||||
}
|
||||
|
||||
public interface QueryFactory {
|
||||
SocketMessage getRequeryMessage();
|
||||
SocketMessage getPageAroundMessage(int offset, int limit);
|
||||
}
|
||||
|
||||
public TrackListScrollCache(RecyclerView recyclerView,
|
||||
RecyclerView.Adapter<?> adapter,
|
||||
WebSocketService wss,
|
||||
QueryFactory queryFactory,
|
||||
Mapper<TrackType> mapper) {
|
||||
this.recyclerView = recyclerView;
|
||||
this.adapter = adapter;
|
||||
this.wss = wss;
|
||||
this.queryFactory = queryFactory;
|
||||
this.mapper = mapper;
|
||||
|
||||
this.recyclerView.addOnScrollListener(scrollListener);
|
||||
this.wss.addClient(this.client);
|
||||
}
|
||||
|
||||
public void requery() {
|
||||
cancelMessages();
|
||||
|
||||
final SocketMessage message = queryFactory.getRequeryMessage();
|
||||
|
||||
wss.send(message, this.client, (SocketMessage response) -> {
|
||||
setCount(response.getIntOption(Messages.Key.COUNT, 0));
|
||||
|
||||
if (initialPosition != -1) {
|
||||
recyclerView.scrollToPosition(initialPosition);
|
||||
initialPosition = -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
this.recyclerView.removeOnScrollListener(scrollListener);
|
||||
this.wss.removeClient(this.client);
|
||||
}
|
||||
|
||||
public void setInitialPosition(int initialIndex) {
|
||||
this.initialPosition = initialIndex;
|
||||
}
|
||||
|
||||
public void setCount(int count) {
|
||||
this.count = count;
|
||||
invalidateCache();
|
||||
cancelMessages();
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public int getCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public TrackType getTrack(int index) {
|
||||
final CacheEntry<TrackType> track = cache.get(index);
|
||||
|
||||
if (track == null || track.dirty) {
|
||||
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
this.getPageAround(index);
|
||||
}
|
||||
}
|
||||
|
||||
return (track == null) ? null : track.value;
|
||||
}
|
||||
|
||||
private void invalidateCache() {
|
||||
for (final CacheEntry<TrackType> entry : cache.values()) {
|
||||
entry.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelMessages() {
|
||||
this.queryOffset = this.queryLimit = -1;
|
||||
this.wss.cancelMessages(this.client);
|
||||
}
|
||||
|
||||
private void getPageAround(int index) {
|
||||
if (index >= queryOffset && index <= queryOffset + queryLimit) {
|
||||
return; /* already in flight */
|
||||
}
|
||||
|
||||
cancelMessages();
|
||||
|
||||
queryOffset = Math.max(0, index - 10); /* snag a couple before */
|
||||
queryLimit = DEFAULT_WINDOW_SIZE;
|
||||
SocketMessage request = this.queryFactory.getPageAroundMessage(queryOffset, queryLimit);
|
||||
|
||||
this.wss.send(request, this.client, (SocketMessage response) -> {
|
||||
this.queryOffset = this.queryLimit = -1;
|
||||
|
||||
final JSONArray data = response.getJsonArrayOption(Messages.Key.DATA);
|
||||
final int offset = response.getIntOption(Messages.Key.OFFSET);
|
||||
if (data != null) {
|
||||
for (int i = 0; i < data.length(); i++) {
|
||||
final JSONObject track = data.optJSONObject(i);
|
||||
if (track != null) {
|
||||
final CacheEntry<TrackType> entry = new CacheEntry<>();
|
||||
entry.dirty = false;
|
||||
entry.value = mapper.map(track);
|
||||
cache.put(offset + i, entry);
|
||||
}
|
||||
}
|
||||
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
|
||||
scrollState = newState;
|
||||
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private WebSocketService.Client client = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
if (message.getType() == SocketMessage.Type.Broadcast) {
|
||||
if (Messages.Broadcast.PlayQueueChanged.is(message.getName())) {
|
||||
requery();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.Fragment;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
public class TransportFragment extends Fragment {
|
||||
public static final String TAG = "TransportFragment";
|
||||
|
||||
public static TransportFragment newInstance() {
|
||||
return new TransportFragment();
|
||||
}
|
||||
|
||||
private WebSocketService wss;
|
||||
private View rootView;
|
||||
private TextView title, playPause;
|
||||
private TransportModel transportModel = new TransportModel();
|
||||
private OnModelChangedListener modelChangedListener;
|
||||
|
||||
public interface OnModelChangedListener {
|
||||
void onChanged(TransportFragment fragment);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater,
|
||||
@Nullable ViewGroup container,
|
||||
@Nullable Bundle savedInstanceState)
|
||||
{
|
||||
this.rootView = inflater.inflate(R.layout.fragment_transport, container, false);
|
||||
bindEventHandlers();
|
||||
rebindUi();
|
||||
return this.rootView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
this.wss = WebSocketService.getInstance(getActivity());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
this.wss.removeClient(socketClient);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
this.wss.addClient(socketClient);
|
||||
}
|
||||
|
||||
public TransportModel getModel() {
|
||||
return transportModel;
|
||||
}
|
||||
|
||||
public void setModelChangedListener(OnModelChangedListener modelChangedListener) {
|
||||
this.modelChangedListener = modelChangedListener;
|
||||
}
|
||||
|
||||
private void bindEventHandlers() {
|
||||
this.title = (TextView) this.rootView.findViewById(R.id.track_title);
|
||||
|
||||
this.title.setOnClickListener((View view) -> {
|
||||
if (transportModel.getPlaybackState() != TransportModel.PlaybackState.Stopped) {
|
||||
final Intent intent = PlayQueueActivity
|
||||
.getStartIntent(getActivity(), transportModel.getQueuePosition())
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
|
||||
startActivity(intent);
|
||||
}
|
||||
});
|
||||
|
||||
this.title.setOnLongClickListener((View view) -> {
|
||||
startActivity(MainActivity.getStartIntent(getActivity()));
|
||||
return true;
|
||||
});
|
||||
|
||||
this.rootView.findViewById(R.id.button_prev).setOnClickListener((View view) -> {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.Previous).build());
|
||||
});
|
||||
|
||||
this.playPause = (TextView) this.rootView.findViewById(R.id.button_play_pause);
|
||||
|
||||
this.playPause.setOnClickListener((View view) -> {
|
||||
if (transportModel.getPlaybackState() == TransportModel.PlaybackState.Stopped) {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.PlayAllTracks).build());
|
||||
}
|
||||
else {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.PauseOrResume).build());
|
||||
}
|
||||
});
|
||||
|
||||
this.rootView.findViewById(R.id.button_next).setOnClickListener((View view) -> {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.Next).build());
|
||||
});
|
||||
}
|
||||
|
||||
private void rebindUi() {
|
||||
TransportModel.PlaybackState state = transportModel.getPlaybackState();
|
||||
|
||||
final boolean playing = (state == TransportModel.PlaybackState.Playing);
|
||||
this.playPause.setText(playing ? R.string.button_pause : R.string.button_play);
|
||||
|
||||
if (state == TransportModel.PlaybackState.Stopped) {
|
||||
title.setTextColor(getActivity().getResources().getColor(R.color.theme_disabled_foreground));
|
||||
title.setText(R.string.transport_not_playing);
|
||||
}
|
||||
else {
|
||||
title.setTextColor(getActivity().getResources().getColor(R.color.theme_green));
|
||||
title.setText(transportModel.getTrackValueString(TransportModel.Key.TITLE, "(unknown title)"));
|
||||
}
|
||||
}
|
||||
|
||||
private WebSocketService.Client socketClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.GetPlaybackOverview.toString()).build());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
if (transportModel.canHandle(message)) {
|
||||
if (transportModel.update(message)) {
|
||||
rebindUi();
|
||||
|
||||
if (modelChangedListener != null) {
|
||||
modelChangedListener.onChanged(TransportFragment.this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,212 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class TransportModel {
|
||||
public interface Key {
|
||||
String STATE = "state";
|
||||
String REPEAT_MODE = "repeat_mode";
|
||||
String VOLUME = "volume";
|
||||
String SHUFFLED = "shuffled";
|
||||
String MUTED = "muted";
|
||||
String PLAY_QUEUE_COUNT = "track_count";
|
||||
String PLAY_QUEUE_POSITION = "play_queue_position";
|
||||
String PLAYING_DURATION = "playing_duration";
|
||||
String PLAYING_CURRENT_TIME = "playing_current_time";
|
||||
String PLAYING_TRACK = "playing_track";
|
||||
String TITLE = "title";
|
||||
String ALBUM = "album";
|
||||
String ARTIST = "artist";
|
||||
String ALBUM_ID = "album_id";
|
||||
String ARTIST_ID = "visual_artist_id";
|
||||
}
|
||||
|
||||
public enum PlaybackState {
|
||||
Unknown("unknown"),
|
||||
Stopped("stopped"),
|
||||
Playing("playing"),
|
||||
Paused("paused");
|
||||
|
||||
private final String rawValue;
|
||||
|
||||
PlaybackState(String rawValue) {
|
||||
this.rawValue = rawValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
static PlaybackState from(final String rawValue) {
|
||||
if (Stopped.rawValue.equals(rawValue)) {
|
||||
return Stopped;
|
||||
}
|
||||
else if (Playing.rawValue.equals(rawValue)) {
|
||||
return Playing;
|
||||
}
|
||||
else if (Paused.rawValue.equals(rawValue)) {
|
||||
return Paused;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("rawValue is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
public enum RepeatMode {
|
||||
None("none"),
|
||||
List("list"),
|
||||
Track("track");
|
||||
|
||||
private final String rawValue;
|
||||
|
||||
RepeatMode(String rawValue) {
|
||||
this.rawValue = rawValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
public static RepeatMode from(final String rawValue) {
|
||||
if (None.rawValue.equals(rawValue)) {
|
||||
return None;
|
||||
}
|
||||
else if (List.rawValue.equals(rawValue)) {
|
||||
return List;
|
||||
}
|
||||
else if (Track.rawValue.equals(rawValue)) {
|
||||
return Track;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("rawValue is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private PlaybackState playbackState = PlaybackState.Unknown;
|
||||
private RepeatMode repeatMode;
|
||||
private boolean shuffled;
|
||||
private boolean muted;
|
||||
private double volume;
|
||||
private int queueCount;
|
||||
private int queuePosition;
|
||||
private double duration;
|
||||
private double currentTime;
|
||||
private JSONObject track = new JSONObject();
|
||||
|
||||
public TransportModel() {
|
||||
reset();
|
||||
}
|
||||
|
||||
public boolean canHandle(SocketMessage socketMessage) {
|
||||
if (socketMessage == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final String name = socketMessage.getName();
|
||||
|
||||
return
|
||||
name.equals(Messages.Broadcast.PlaybackOverviewChanged.toString()) ||
|
||||
name.equals(Messages.Request.GetPlaybackOverview.toString());
|
||||
}
|
||||
|
||||
public boolean update(SocketMessage message) {
|
||||
if (message == null) {
|
||||
reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
final String name = message.getName();
|
||||
|
||||
if (!name.equals(Messages.Broadcast.PlaybackOverviewChanged.toString()) &&
|
||||
!name.equals(Messages.Request.GetPlaybackOverview.toString()))
|
||||
{
|
||||
throw new IllegalArgumentException("invalid message!");
|
||||
}
|
||||
|
||||
playbackState = PlaybackState.from(message.getStringOption(Key.STATE));
|
||||
repeatMode = RepeatMode.from(message.getStringOption(Key.REPEAT_MODE));
|
||||
shuffled = message.getBooleanOption(Key.SHUFFLED);
|
||||
muted = message.getBooleanOption(Key.MUTED);
|
||||
volume = message.getDoubleOption(Key.VOLUME);
|
||||
queueCount = message.getIntOption(Key.PLAY_QUEUE_COUNT);
|
||||
queuePosition = message.getIntOption(Key.PLAY_QUEUE_POSITION);
|
||||
duration = message.getDoubleOption(Key.PLAYING_DURATION);
|
||||
currentTime = message.getDoubleOption(Key.PLAYING_CURRENT_TIME);
|
||||
track = message.getJsonObjectOption(Key.PLAYING_TRACK, new JSONObject());
|
||||
return true;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
playbackState = PlaybackState.Unknown;
|
||||
repeatMode = RepeatMode.None;
|
||||
shuffled = muted = false;
|
||||
volume = 0.0f;
|
||||
queueCount = queuePosition = 0;
|
||||
duration = currentTime = 0.0f;
|
||||
track = new JSONObject();
|
||||
}
|
||||
|
||||
public PlaybackState getPlaybackState() {
|
||||
return playbackState;
|
||||
}
|
||||
|
||||
public RepeatMode getRepeatMode() {
|
||||
return repeatMode;
|
||||
}
|
||||
|
||||
public void setRepeatMode(final RepeatMode repeatMode) {
|
||||
this.repeatMode = repeatMode;
|
||||
}
|
||||
|
||||
public boolean isShuffled() {
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
public boolean isMuted() {
|
||||
return muted;
|
||||
}
|
||||
|
||||
public double getVolume() {
|
||||
return volume;
|
||||
}
|
||||
|
||||
public int getQueueCount() {
|
||||
return queueCount;
|
||||
}
|
||||
|
||||
public int getQueuePosition() {
|
||||
return queuePosition;
|
||||
}
|
||||
|
||||
public double getDuration() {
|
||||
return duration;
|
||||
}
|
||||
|
||||
public double getCurrentTime() {
|
||||
return currentTime;
|
||||
}
|
||||
|
||||
public long getTrackValueLong(final String key) {
|
||||
return getTrackValueLong(key, -1);
|
||||
}
|
||||
|
||||
public long getTrackValueLong(final String key, long defaultValue) {
|
||||
if (track.has(key)) {
|
||||
return track.optLong(key, defaultValue);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public String getTrackValueString(final String key) {
|
||||
return getTrackValueString(key, "-");
|
||||
}
|
||||
|
||||
public String getTrackValueString(final String key, final String defaultValue) {
|
||||
if (track.has(key)) {
|
||||
return track.optString(key, defaultValue);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.app.SearchManager;
|
||||
import android.app.SearchableInfo;
|
||||
import android.content.Context;
|
||||
import android.support.v4.view.MenuItemCompat;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.support.v7.widget.DividerItemDecoration;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.support.v7.widget.SearchView;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewPropertyAnimator;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.EditText;
|
||||
|
||||
public final class Views {
|
||||
public static void setCheckWithoutEvent(final CheckBox cb,
|
||||
final boolean checked,
|
||||
final CheckBox.OnCheckedChangeListener listener) {
|
||||
cb.setOnCheckedChangeListener(null);
|
||||
cb.setChecked(checked);
|
||||
cb.setOnCheckedChangeListener(listener);
|
||||
}
|
||||
|
||||
public static void setupDefaultRecyclerView(final Context context,
|
||||
final RecyclerView recyclerView,
|
||||
final RecyclerView.Adapter adapter) {
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(context);
|
||||
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(context));
|
||||
recyclerView.setAdapter(adapter);
|
||||
|
||||
final DividerItemDecoration dividerItemDecoration =
|
||||
new DividerItemDecoration(context, layoutManager.getOrientation());
|
||||
|
||||
recyclerView.addItemDecoration(dividerItemDecoration);
|
||||
}
|
||||
|
||||
public static TransportFragment addTransportFragment(final AppCompatActivity activity) {
|
||||
return addTransportFragment(activity, null);
|
||||
}
|
||||
|
||||
public static TransportFragment addTransportFragment(
|
||||
final AppCompatActivity activity, TransportFragment.OnModelChangedListener listener)
|
||||
{
|
||||
final View root = activity.findViewById(android.R.id.content);
|
||||
if (root != null) {
|
||||
if (root.findViewById(R.id.transport_container) != null) {
|
||||
final TransportFragment fragment = TransportFragment.newInstance();
|
||||
|
||||
activity.getSupportFragmentManager()
|
||||
.beginTransaction()
|
||||
.add(R.id.transport_container, fragment, TransportFragment.TAG)
|
||||
.commit();
|
||||
|
||||
fragment.setModelChangedListener(listener);
|
||||
return fragment;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void initSearchMenu(final AppCompatActivity activity,
|
||||
final Menu menu,
|
||||
final Filterable filterable) {
|
||||
activity.getMenuInflater().inflate(R.menu.search_menu, menu);
|
||||
|
||||
final MenuItem searchMenuItem = menu.findItem(R.id.action_search);
|
||||
|
||||
final SearchView searchView =
|
||||
(SearchView) MenuItemCompat .getActionView(searchMenuItem);
|
||||
|
||||
searchView.setMaxWidth(Integer.MAX_VALUE);
|
||||
|
||||
if (filterable != null) {
|
||||
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
|
||||
@Override
|
||||
public boolean onQueryTextSubmit(String query) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onQueryTextChange(String newText) {
|
||||
filterable.setFilter(newText);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
searchView.setOnCloseListener(() -> {
|
||||
filterable.setFilter("");
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
final SearchManager searchManager = (SearchManager)
|
||||
activity.getSystemService(Context.SEARCH_SERVICE);
|
||||
|
||||
final SearchableInfo searchableInfo = searchManager
|
||||
.getSearchableInfo(activity.getComponentName());
|
||||
|
||||
searchView.setSearchableInfo(searchableInfo);
|
||||
searchView.setIconifiedByDefault(true);
|
||||
}
|
||||
|
||||
public static void setTextAndMoveCursorToEnd(final EditText editText, final String text) {
|
||||
editText.setText(text);
|
||||
editText.setSelection(editText.getText().length());
|
||||
}
|
||||
|
||||
public static ViewPropertyAnimator animateAlpha(final View view, final float value) {
|
||||
final ViewPropertyAnimator animator = view.animate().alpha(value).setDuration(300);
|
||||
animator.start();
|
||||
return animator;
|
||||
}
|
||||
|
||||
|
||||
private Views() {
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.view.KeyEvent;
|
||||
|
||||
public abstract class WebSocketActivityBase extends AppCompatActivity {
|
||||
private WebSocketService wss;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
this.wss = WebSocketService.getInstance(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
this.wss.removeClient(getWebSocketServiceClient());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
this.wss.addClient(getWebSocketServiceClient());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SetVolume)
|
||||
.addOption(TransportModel.Key.VOLUME, -0.05)
|
||||
.addOption(Messages.Key.RELATIVE, true)
|
||||
.build());
|
||||
return true;
|
||||
}
|
||||
else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SetVolume)
|
||||
.addOption(TransportModel.Key.VOLUME, 0.05)
|
||||
.addOption(Messages.Key.RELATIVE, true)
|
||||
.build());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
protected final WebSocketService getWebSocketService() {
|
||||
return this.wss;
|
||||
}
|
||||
|
||||
protected abstract WebSocketService.Client getWebSocketServiceClient();
|
||||
}
|
@ -0,0 +1,470 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
|
||||
import com.neovisionaries.ws.client.WebSocket;
|
||||
import com.neovisionaries.ws.client.WebSocketAdapter;
|
||||
import com.neovisionaries.ws.client.WebSocketFactory;
|
||||
import com.neovisionaries.ws.client.WebSocketFrame;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static android.content.Context.CONNECTIVITY_SERVICE;
|
||||
|
||||
public class WebSocketService {
|
||||
private static final int AUTO_RECONNECT_INTERVAL_MILLIS = 2000;
|
||||
private static final int CALLBACK_TIMEOUT_MILLIS = 30000;
|
||||
private static final int CONNECTION_TIMEOUT_MILLIS = 5000;
|
||||
private static final int PING_INTERVAL_MILLIS = 3500;
|
||||
private static final int AUTO_CONNECT_FAILSAFE_DELAY_MILLIS = 2000;
|
||||
private static final int AUTO_DISCONNECT_DELAY_MILLIS = 5000;
|
||||
|
||||
private static final int MESSAGE_BASE = 0xcafedead;
|
||||
private static final int MESSAGE_CONNECT_THREAD_FINISHED = MESSAGE_BASE + 0;
|
||||
private static final int MESSAGE_MESSAGE_RECEIVED = MESSAGE_BASE + 1;
|
||||
private static final int MESSAGE_REMOVE_OLD_CALLBACKS = MESSAGE_BASE + 2;
|
||||
private static final int MESSAGE_AUTO_RECONNECT = MESSAGE_BASE + 3;
|
||||
private static final int MESSAGE_SCHEDULE_PING = MESSAGE_BASE + 4;
|
||||
private static final int MESSAGE_PING_EXPIRED = MESSAGE_BASE + 5;
|
||||
|
||||
public interface Client {
|
||||
void onStateChanged(State newState, State oldState);
|
||||
void onMessageReceived(SocketMessage message);
|
||||
}
|
||||
|
||||
public interface MessageResultCallback {
|
||||
void onMessageResult(final SocketMessage response);
|
||||
}
|
||||
|
||||
private interface Predicate1<T> {
|
||||
boolean check(T value);
|
||||
}
|
||||
|
||||
public enum State {
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnected
|
||||
}
|
||||
|
||||
private Handler handler = new Handler(Looper.getMainLooper(), new Handler.Callback() {
|
||||
@Override
|
||||
public boolean handleMessage(Message message) {
|
||||
if (message.what == MESSAGE_CONNECT_THREAD_FINISHED) {
|
||||
if (message.obj == null) {
|
||||
disconnect(true); /* auto-reconnect */
|
||||
}
|
||||
else {
|
||||
setSocket((WebSocket) message.obj);
|
||||
setState(State.Connected);
|
||||
ping();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else if (message.what == MESSAGE_MESSAGE_RECEIVED) {
|
||||
if (clients != null) {
|
||||
final SocketMessage msg = (SocketMessage) message.obj;
|
||||
|
||||
boolean dispatched = false;
|
||||
|
||||
/* registered callback for THIS message */
|
||||
final MessageResultDescriptor mdr = messageCallbacks.remove(msg.getId());
|
||||
if (mdr != null && mdr.callback != null) {
|
||||
mdr.callback.onMessageResult(msg);
|
||||
dispatched = true;
|
||||
}
|
||||
|
||||
if (!dispatched) {
|
||||
/* service-level callback */
|
||||
for (Client client : clients) {
|
||||
client.onMessageReceived(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else if (message.what == MESSAGE_REMOVE_OLD_CALLBACKS) {
|
||||
removeExpiredCallbacks();
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_REMOVE_OLD_CALLBACKS, CALLBACK_TIMEOUT_MILLIS);
|
||||
}
|
||||
else if (message.what == MESSAGE_AUTO_RECONNECT) {
|
||||
if (getState() == State.Disconnected && autoReconnect) {
|
||||
reconnect();
|
||||
}
|
||||
}
|
||||
else if (message.what == MESSAGE_SCHEDULE_PING) {
|
||||
ping();
|
||||
}
|
||||
else if (message.what == MESSAGE_PING_EXPIRED) {
|
||||
// Toast.makeText(context, "recovering...", Toast.LENGTH_LONG).show();
|
||||
removeInternalCallbacks();
|
||||
boolean reconnect = (getState() == State.Connected) || autoReconnect;
|
||||
disconnect(reconnect);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
private static class MessageResultDescriptor {
|
||||
long id;
|
||||
long enqueueTime;
|
||||
Client client;
|
||||
MessageResultCallback callback;
|
||||
}
|
||||
|
||||
private static WebSocketService INSTANCE;
|
||||
private static AtomicLong NEXT_ID = new AtomicLong(0);
|
||||
|
||||
private Context context;
|
||||
private SharedPreferences prefs;
|
||||
private WebSocket socket = null;
|
||||
private State state = State.Disconnected;
|
||||
private Set<Client> clients = new HashSet<>();
|
||||
private Map<String, MessageResultDescriptor> messageCallbacks = new HashMap<>();
|
||||
private boolean autoReconnect = false;
|
||||
private NetworkChangedReceiver networkChanged = new NetworkChangedReceiver();
|
||||
private ConnectThread thread;
|
||||
|
||||
public static synchronized WebSocketService getInstance(final Context context) {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = new WebSocketService(context);
|
||||
}
|
||||
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private WebSocketService(final Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.prefs = this.context.getSharedPreferences("prefs", Context.MODE_PRIVATE);
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_REMOVE_OLD_CALLBACKS, CALLBACK_TIMEOUT_MILLIS);
|
||||
}
|
||||
|
||||
public void addClient(Client client) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
if (!this.clients.contains(client)) {
|
||||
this.clients.add(client);
|
||||
|
||||
if (this.clients.size() == 1) {
|
||||
registerReceiverAndScheduleFailsafe();
|
||||
handler.removeCallbacks(autoDisconnectRunnable);
|
||||
}
|
||||
|
||||
client.onStateChanged(getState(), getState());
|
||||
}
|
||||
}
|
||||
|
||||
public void removeClient(Client client) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
if (this.clients.remove(client)) {
|
||||
removeCallbacksForClient(client);
|
||||
|
||||
if (this.clients.size() == 0) {
|
||||
unregisterReceiverAndCancelFailsafe();
|
||||
handler.postDelayed(autoDisconnectRunnable, AUTO_DISCONNECT_DELAY_MILLIS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void reconnect() {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
autoReconnect = true;
|
||||
connectIfNotConnected();
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
disconnect(false); /* don't auto-reconnect */
|
||||
}
|
||||
|
||||
public State getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public void cancelMessage(final long id) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
removeCallbacks((MessageResultDescriptor mrd) -> mrd.id == id);
|
||||
}
|
||||
|
||||
private void ping() {
|
||||
if (state == State.Connected) {
|
||||
//Log.i("WebSocketService", "ping");
|
||||
removeInternalCallbacks();
|
||||
handler.removeMessages(MESSAGE_PING_EXPIRED);
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_PING_EXPIRED, PING_INTERVAL_MILLIS);
|
||||
|
||||
final SocketMessage ping = SocketMessage.Builder
|
||||
.request(Messages.Request.Ping).build();
|
||||
|
||||
send(ping, INTERNAL_CLIENT, (SocketMessage response) -> {
|
||||
//Log.i("WebSocketService", "pong");
|
||||
handler.removeMessages(MESSAGE_PING_EXPIRED);
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_SCHEDULE_PING, PING_INTERVAL_MILLIS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void cancelMessages(final Client client) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
removeCallbacks((MessageResultDescriptor mrd) -> mrd.client == client);
|
||||
}
|
||||
|
||||
public long send(final SocketMessage message) {
|
||||
return send(message, null, null);
|
||||
}
|
||||
|
||||
public long send(final SocketMessage message, Client client, MessageResultCallback callback) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
if (this.socket != null) {
|
||||
/* it seems that sometimes the socket dies, but the onDisconnected() event is not
|
||||
raised. unclear if this is our bug or a bug in the library. disconnect and trigger
|
||||
a reconnect until we can find a better root cause. this is very difficult to repro */
|
||||
if (!this.socket.isOpen()) {
|
||||
this.disconnect(true);
|
||||
}
|
||||
else {
|
||||
long id = NEXT_ID.incrementAndGet();
|
||||
|
||||
if (callback != null) {
|
||||
if (!clients.contains(client) && client != INTERNAL_CLIENT) {
|
||||
throw new IllegalArgumentException("client is not registered");
|
||||
}
|
||||
|
||||
final MessageResultDescriptor mrd = new MessageResultDescriptor();
|
||||
mrd.id = id;
|
||||
mrd.enqueueTime = System.currentTimeMillis();
|
||||
mrd.client = client;
|
||||
mrd.callback = callback;
|
||||
messageCallbacks.put(message.getId(), mrd);
|
||||
}
|
||||
|
||||
this.socket.sendText(message.toString());
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public boolean hasValidConnection() {
|
||||
final String addr = prefs.getString("address", "");
|
||||
final int port = prefs.getInt("port", -1);
|
||||
return (addr.length() > 0 && port >= 0);
|
||||
}
|
||||
|
||||
private void disconnect(boolean autoReconnect) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
synchronized (this) {
|
||||
if (this.thread != null) {
|
||||
this.thread.interrupt();
|
||||
this.thread = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.autoReconnect = autoReconnect;
|
||||
|
||||
if (this.socket != null) {
|
||||
this.socket.removeListener(webSocketAdapter);
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
this.messageCallbacks.clear();
|
||||
setState(State.Disconnected);
|
||||
|
||||
if (autoReconnect) {
|
||||
this.handler.sendEmptyMessageDelayed(
|
||||
MESSAGE_AUTO_RECONNECT,
|
||||
AUTO_RECONNECT_INTERVAL_MILLIS);
|
||||
}
|
||||
else {
|
||||
this.handler.removeMessages(MESSAGE_AUTO_RECONNECT);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeInternalCallbacks() {
|
||||
removeCallbacks((MessageResultDescriptor mrd) -> mrd.client == INTERNAL_CLIENT);
|
||||
}
|
||||
|
||||
private void removeExpiredCallbacks() {
|
||||
final long now = System.currentTimeMillis();
|
||||
|
||||
removeCallbacks((MessageResultDescriptor value) ->
|
||||
now - value.enqueueTime > CALLBACK_TIMEOUT_MILLIS);
|
||||
}
|
||||
|
||||
private void removeCallbacksForClient(final Client client) {
|
||||
removeCallbacks((MessageResultDescriptor value) -> value == client);
|
||||
}
|
||||
|
||||
private void removeCallbacks(Predicate1<MessageResultDescriptor> predicate) {
|
||||
final Iterator<Map.Entry<String, MessageResultDescriptor>> it
|
||||
= messageCallbacks.entrySet().iterator();
|
||||
|
||||
while (it.hasNext()) {
|
||||
final Map.Entry<String, MessageResultDescriptor> entry = it.next();
|
||||
if (predicate.check(entry.getValue())) {
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void connectIfNotConnected() {
|
||||
if (state != State.Connected || !socket.isOpen()) {
|
||||
disconnect(autoReconnect);
|
||||
handler.removeMessages(MESSAGE_AUTO_RECONNECT);
|
||||
setState(State.Connecting);
|
||||
thread = new ConnectThread();
|
||||
thread.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void setSocket(WebSocket socket) {
|
||||
if (this.socket != socket) {
|
||||
if (this.socket != null) {
|
||||
this.socket.removeListener(webSocketAdapter);
|
||||
}
|
||||
|
||||
this.socket = socket;
|
||||
this.socket.addListener(webSocketAdapter);
|
||||
}
|
||||
}
|
||||
|
||||
private void setState(State state) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
if (this.state != state) {
|
||||
State old = this.state;
|
||||
this.state = state;
|
||||
|
||||
for (Client client : this.clients) {
|
||||
client.onStateChanged(state, old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void registerReceiverAndScheduleFailsafe() {
|
||||
unregisterReceiverAndCancelFailsafe();
|
||||
|
||||
/* generally raises a CONNECTIVITY_ACTION event immediately,
|
||||
even if already connected. */
|
||||
final IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
|
||||
context.registerReceiver(networkChanged, filter);
|
||||
|
||||
/* however, CONNECTIVITY_ACTION doesn't ALWAYS seem to be raised,
|
||||
so we schedule a failsafe just in case */
|
||||
this.handler.postDelayed(autoReconnectFailsafeRunnable, AUTO_CONNECT_FAILSAFE_DELAY_MILLIS);
|
||||
}
|
||||
|
||||
private void unregisterReceiverAndCancelFailsafe() {
|
||||
handler.removeCallbacks(autoReconnectFailsafeRunnable);
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(networkChanged);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
/* om nom nom */
|
||||
}
|
||||
}
|
||||
|
||||
private Runnable autoReconnectFailsafeRunnable = () -> {
|
||||
if (getState() != WebSocketService.State.Connected) {
|
||||
reconnect();
|
||||
}
|
||||
};
|
||||
|
||||
private Runnable autoDisconnectRunnable = () -> disconnect();
|
||||
|
||||
private WebSocketAdapter webSocketAdapter = new WebSocketAdapter() {
|
||||
@Override
|
||||
public void onTextFrame(WebSocket websocket, WebSocketFrame frame) throws Exception {
|
||||
final SocketMessage message = SocketMessage.create(frame.getPayloadText());
|
||||
if (message != null) {
|
||||
handler.sendMessage(Message.obtain(handler, MESSAGE_MESSAGE_RECEIVED, message));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(WebSocket websocket,
|
||||
WebSocketFrame serverCloseFrame,
|
||||
WebSocketFrame clientCloseFrame,
|
||||
boolean closedByServer) throws Exception {
|
||||
handler.sendMessage(Message.obtain(handler, MESSAGE_CONNECT_THREAD_FINISHED, null));
|
||||
}
|
||||
};
|
||||
|
||||
private class ConnectThread extends Thread {
|
||||
@Override
|
||||
public void run() {
|
||||
WebSocket socket;
|
||||
|
||||
try {
|
||||
final WebSocketFactory factory = new WebSocketFactory();
|
||||
|
||||
final String host = String.format(
|
||||
Locale.ENGLISH,
|
||||
"ws://%s:%d",
|
||||
prefs.getString("address", "192.168.1.100"),
|
||||
prefs.getInt("port", 9002));
|
||||
|
||||
socket = factory.createSocket(host, CONNECTION_TIMEOUT_MILLIS);
|
||||
socket.connect();
|
||||
socket.setPingInterval(PING_INTERVAL_MILLIS);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
socket = null;
|
||||
}
|
||||
|
||||
synchronized (WebSocketService.this) {
|
||||
if (!isInterrupted()) {
|
||||
handler.sendMessage(Message.obtain(
|
||||
handler, MESSAGE_CONNECT_THREAD_FINISHED, socket));
|
||||
}
|
||||
|
||||
if (thread == this) {
|
||||
thread = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NetworkChangedReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
final ConnectivityManager cm = (ConnectivityManager)
|
||||
context.getSystemService(CONNECTIVITY_SERVICE);
|
||||
|
||||
final NetworkInfo info = cm.getActiveNetworkInfo();
|
||||
|
||||
if (info != null && info.isConnected()) {
|
||||
if (getState() == WebSocketService.State.Disconnected) {
|
||||
disconnect();
|
||||
reconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Client INTERNAL_CLIENT = new Client() {
|
||||
public void onStateChanged(State newState, State oldState) { }
|
||||
public void onMessageReceived(SocketMessage message) { }
|
||||
};
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="?attr/colorControlHighlight">
|
||||
<item android:drawable="@color/theme_button_background"/>
|
||||
</ripple>
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="?attr/colorControlHighlight">
|
||||
<item android:drawable="@color/theme_background"/>
|
||||
</ripple>
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="?attr/colorControlHighlight">
|
||||
<item android:drawable="@color/theme_selected_background"/>
|
||||
</ripple>
|
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="?attr/colorControlHighlight">
|
||||
<item
|
||||
android:drawable="@color/theme_button_background"/>
|
||||
</ripple>
|
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/theme_button_background"/>
|
||||
</selector>
|
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/theme_background"/>
|
||||
</selector>
|
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/theme_selected_background"/>
|
||||
</selector>
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:drawable="@color/theme_selected_background"/>
|
||||
</selector>
|
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true"
|
||||
android:color="@color/theme_green" />
|
||||
|
||||
<item android:color="@color/theme_disabled_foreground" />
|
||||
</selector>
|
354
src/musikdroid/app/src/main/res/layout/activity_main.xml
Normal file
354
src/musikdroid/app/src/main/res/layout/activity_main.xml
Normal file
@ -0,0 +1,354 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/activity_main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
tools:context="io.casey.musikcube.remote.MainActivity">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/metadata_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_above="@+id/play_controls">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/album_art"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/connected"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:textColor="@color/theme_green"
|
||||
android:text="connected"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/main_not_playing"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:textSize="@dimen/text_size_large"
|
||||
android:textColor="@color/theme_disabled_foreground"
|
||||
android:padding="8dp"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/not_playing_button"
|
||||
android:text="@string/transport_not_playing"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/main_track_metadata_with_art"
|
||||
android:background="@color/main_playback_metadata"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="2dp">
|
||||
|
||||
<TextView
|
||||
style="@style/LightDropShadow"
|
||||
android:id="@+id/with_art_track_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_gravity="center"
|
||||
android:textColor="@color/theme_green"
|
||||
android:text="-"/>
|
||||
|
||||
<TextView
|
||||
style="@style/LightDropShadow"
|
||||
android:id="@+id/with_art_artist_and_album"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_gravity="center"
|
||||
android:textColor="@color/theme_yellow"
|
||||
android:text="-"/>
|
||||
|
||||
<TextView
|
||||
style="@style/LightDropShadow"
|
||||
android:id="@+id/with_art_volume"
|
||||
android:textSize="@dimen/text_size_small"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text=""/>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/main_track_metadata_without_art"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="6dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_gravity="center"
|
||||
android:textColor="@color/theme_green"
|
||||
android:textSize="@dimen/text_size_xxlarge"
|
||||
android:text="-"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_album"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_gravity="center"
|
||||
android:textColor="@color/theme_orange"
|
||||
android:textSize="@dimen/text_size_xlarge"
|
||||
android:text="-"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_artist"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="@color/theme_yellow"
|
||||
android:textSize="@dimen/text_size_large"
|
||||
android:text="-"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/volume"
|
||||
android:textSize="@dimen/text_size_small"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_gravity="center"
|
||||
android:text=""/>
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/play_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerHorizontal="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:orientation="vertical">
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="2dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
style="@style/BrowseButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_artists"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/button_artists"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<TextView
|
||||
style="@style/BrowseButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_albums"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/button_albums"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<TextView
|
||||
style="@style/BrowseButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_tracks"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/button_tracks"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<TextView
|
||||
style="@style/BrowseButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_play_queue"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/button_play_queue"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="2dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_prev"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_prev"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<TextView
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_play_pause"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_play"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<TextView
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_next"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_next"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="2dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<io.casey.musikcube.remote.LongPressTextView
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_vol_down"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_vol_down"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<io.casey.musikcube.remote.LongPressTextView
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_seek_back"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_seek_back"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<io.casey.musikcube.remote.LongPressTextView
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_seek_forward"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_seek_forward"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<io.casey.musikcube.remote.LongPressTextView
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_weight="1.0"
|
||||
android:id="@+id/button_vol_up"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_vol_up"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:layout_marginTop="2dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<CheckBox
|
||||
style="@style/PlaybackCheckbox"
|
||||
android:id="@+id/check_shuffle"
|
||||
android:layout_weight="1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/button_shuffle"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<CheckBox
|
||||
style="@style/PlaybackCheckbox"
|
||||
android:id="@+id/check_mute"
|
||||
android:layout_weight="1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_mute"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<CheckBox
|
||||
style="@style/PlaybackCheckbox"
|
||||
android:id="@+id/check_repeat"
|
||||
android:layout_weight="1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_repeat_off"/>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
83
src/musikdroid/app/src/main/res/layout/activity_settings.xml
Normal file
83
src/musikdroid/app/src/main/res/layout/activity_settings.xml
Normal file
@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:textColor="@color/theme_foreground"
|
||||
android:text="@string/edit_connection_info"/>
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:layout_marginLeft="24dp">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/address"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:hint="@string/edit_connection_hostname"
|
||||
android:inputType="textNoSuggestions" />
|
||||
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:layout_marginLeft="24dp">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/port"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:hint="@string/edit_connection_port"
|
||||
android:inputType="number" />
|
||||
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="8dp"/>
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/album_art_checkbox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/theme_foreground"
|
||||
android:text="enable album art (uses last.fm)"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<TextView
|
||||
style="@style/BrowseButton"
|
||||
android:id="@+id/button_connect"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="12dp"
|
||||
android:layout_gravity="right"
|
||||
android:text="@string/button_save"/>
|
||||
|
||||
</LinearLayout>
|
@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="2dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/button_prev"
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1.0"
|
||||
android:text="@string/button_prev"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/button_play_pause"
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1.0"
|
||||
android:text="@string/button_play"/>
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="0dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/button_next"
|
||||
style="@style/PlaybackButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1.0"
|
||||
android:text="@string/button_next"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<android.support.v4.widget.Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="2dp"/>
|
||||
|
||||
<TextView
|
||||
style="@style/TransportPlaying"
|
||||
android:id="@+id/track_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/theme_disabled_foreground"
|
||||
android:text="@string/transport_not_playing"/>
|
||||
|
||||
</LinearLayout>
|
54
src/musikdroid/app/src/main/res/layout/play_queue_row.xml
Normal file
54
src/musikdroid/app/src/main/res/layout/play_queue_row.xml
Normal file
@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:selectableItemBackground"
|
||||
android:minHeight="52dp"
|
||||
android:padding="8dp" >
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_num"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:textColor="@color/theme_disabled_foreground"
|
||||
android:text="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_toRightOf="@id/track_num"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/theme_foreground"
|
||||
android:text="title"/>
|
||||
|
||||
<TextView
|
||||
android:textSize="12dp"
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/theme_disabled_foreground"
|
||||
android:text="subtitle"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</RelativeLayout>
|
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<android.support.v7.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_weight="1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/transport_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="4dp"/>
|
||||
|
||||
</LinearLayout>
|
41
src/musikdroid/app/src/main/res/layout/simple_list_item.xml
Normal file
41
src/musikdroid/app/src/main/res/layout/simple_list_item.xml
Normal file
@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:selectableItemBackground"
|
||||
android:minHeight="52dp"
|
||||
android:padding="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/theme_foreground"
|
||||
android:text="title"/>
|
||||
|
||||
<TextView
|
||||
android:textSize="12dp"
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/theme_disabled_foreground"
|
||||
android:text="subtitle"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
8
src/musikdroid/app/src/main/res/menu/main_menu.xml
Normal file
8
src/musikdroid/app/src/main/res/menu/main_menu.xml
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
app:showAsAction="never"
|
||||
android:title="@string/menu_settings"/>
|
||||
</menu>
|
9
src/musikdroid/app/src/main/res/menu/search_menu.xml
Normal file
9
src/musikdroid/app/src/main/res/menu/search_menu.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/action_search"
|
||||
app:actionViewClass="android.support.v7.widget.SearchView"
|
||||
app:showAsAction="ifRoom"
|
||||
android:title="@string/search_hint"/>
|
||||
</menu>
|
BIN
src/musikdroid/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/musikdroid/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
BIN
src/musikdroid/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/musikdroid/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
BIN
src/musikdroid/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/musikdroid/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
BIN
src/musikdroid/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/musikdroid/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.5 KiB |
BIN
src/musikdroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/musikdroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
6
src/musikdroid/app/src/main/res/values-w820dp/dimens.xml
Normal file
6
src/musikdroid/app/src/main/res/values-w820dp/dimens.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<!-- Example customization of dimensions originally defined in res/values/dimens.xml
|
||||
(such as screen margins) for screens with more than 820dp of available width. This
|
||||
would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). -->
|
||||
<dimen name="activity_horizontal_margin">64dp</dimen>
|
||||
</resources>
|
19
src/musikdroid/app/src/main/res/values/colors.xml
Normal file
19
src/musikdroid/app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="color_primary">#44661f</color>
|
||||
<color name="color_primary_dark">#2c4214</color>
|
||||
<color name="color_accent">#d1ff9f</color>
|
||||
|
||||
<color name="theme_red">#DC5256</color>
|
||||
<color name="theme_green">#A6E22E</color>
|
||||
<color name="theme_yellow">#E6DC74</color>
|
||||
<color name="theme_orange">#FF9620</color>
|
||||
<color name="theme_blue">#66D9EE</color>
|
||||
<color name="theme_disabled_foreground">#808080</color>
|
||||
<color name="theme_foreground">#E6E6E6</color>
|
||||
<color name="theme_selected_background">#424238</color>
|
||||
<color name="theme_button_background">#303030</color>
|
||||
<color name="theme_button_background_transparent">#CF000000</color>
|
||||
<color name="theme_background">#202020</color>
|
||||
<color name="main_playback_metadata">#bb000000</color>
|
||||
</resources>
|
6
src/musikdroid/app/src/main/res/values/dimens.xml
Normal file
6
src/musikdroid/app/src/main/res/values/dimens.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<dimen name="text_size_xxlarge">24sp</dimen>
|
||||
<dimen name="text_size_xlarge">20sp</dimen>
|
||||
<dimen name="text_size_large">16sp</dimen>
|
||||
<dimen name="text_size_small">12sp</dimen>
|
||||
</resources>
|
32
src/musikdroid/app/src/main/res/values/strings.xml
Normal file
32
src/musikdroid/app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,32 @@
|
||||
<resources>
|
||||
<string name="app_name">musikdroid</string>
|
||||
<string name="button_pause">pause</string>
|
||||
<string name="button_play">play</string>
|
||||
<string name="button_prev">prev</string>
|
||||
<string name="button_seek_back">< seek</string>
|
||||
<string name="button_next">next</string>
|
||||
<string name="button_seek_forward">seek ></string>
|
||||
<string name="button_change_connection">change connection</string>
|
||||
<string name="button_tracks">songs</string>
|
||||
<string name="button_albums">albums</string>
|
||||
<string name="button_artists">artists</string>
|
||||
<string name="button_play_queue">queue</string>
|
||||
<string name="button_vol_up">vol +</string>
|
||||
<string name="button_vol_down">vol -</string>
|
||||
<string name="button_shuffle">shuffle</string>
|
||||
<string name="button_mute">mute</string>
|
||||
<string name="button_repeat_off">repeat off</string>
|
||||
<string name="button_repeat_list">repeat list</string>
|
||||
<string name="button_repeat_track">repeat song</string>
|
||||
<string name="button_save">save</string>
|
||||
<string name="status_connecting">connecting</string>
|
||||
<string name="status_disconnected">disconnected</string>
|
||||
<string name="status_connected">connected to %1$s</string>
|
||||
<string name="status_volume">volume %1d%%</string>
|
||||
<string name="edit_connection_info">connection info:</string>
|
||||
<string name="edit_connection_hostname">ip address or hostname</string>
|
||||
<string name="edit_connection_port">port</string>
|
||||
<string name="transport_not_playing">not playing</string>
|
||||
<string name="search_hint">search</string>
|
||||
<string name="menu_settings">settings</string>
|
||||
</resources>
|
54
src/musikdroid/app/src/main/res/values/styles.xml
Normal file
54
src/musikdroid/app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,54 @@
|
||||
<resources>
|
||||
|
||||
<style name="AppTheme" parent="@style/Theme.AppCompat">
|
||||
<item name="colorPrimary">@color/color_primary</item>
|
||||
<item name="colorPrimaryDark">@color/color_primary_dark</item>
|
||||
<item name="colorAccent">@color/color_accent</item>
|
||||
<item name="android:windowBackground">@color/theme_background</item>
|
||||
</style>
|
||||
|
||||
<style name="BrowseButton">
|
||||
<item name="android:layout_margin">0dp</item>
|
||||
<item name="android:padding">12dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:textColor">@color/theme_foreground</item>
|
||||
<item name="android:background">@drawable/category_button</item>
|
||||
<item name="android:clickable">true</item>
|
||||
</style>
|
||||
|
||||
<style name="TransportPlaying">
|
||||
<item name="android:layout_margin">0dp</item>
|
||||
<item name="android:paddingLeft">4dp</item>
|
||||
<item name="android:paddingRight">4dp</item>
|
||||
<item name="android:paddingTop">6dp</item>
|
||||
<item name="android:paddingBottom">6dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:textColor">@color/theme_foreground</item>
|
||||
<item name="android:background">@drawable/category_button</item>
|
||||
<item name="android:clickable">true</item>
|
||||
</style>
|
||||
|
||||
<style name="PlaybackButton">
|
||||
<item name="android:layout_margin">0dp</item>
|
||||
<item name="android:padding">12dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:textColor">@color/theme_foreground</item>
|
||||
<item name="android:background">@drawable/playback_button</item>
|
||||
</style>
|
||||
|
||||
<style name="PlaybackCheckbox">
|
||||
<item name="android:button">@drawable/playback_button</item>
|
||||
<item name="android:minHeight">36dp</item>
|
||||
<item name="android:background">@drawable/playback_checkbox</item>
|
||||
<item name="android:textColor">@drawable/playback_checkbox_text</item>
|
||||
<item name="android:gravity">center</item>
|
||||
</style>
|
||||
|
||||
<style name="LightDropShadow">
|
||||
<item name="android:shadowColor">#000000</item>
|
||||
<item name="android:shadowDx">0</item>
|
||||
<item name="android:shadowDy">0</item>
|
||||
<item name="android:shadowRadius">3</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
6
src/musikdroid/app/src/main/res/xml/searchable.xml
Normal file
6
src/musikdroid/app/src/main/res/xml/searchable.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<searchable
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:label="@string/app_name"
|
||||
android:hint="@string/search_hint" >
|
||||
</searchable>
|
@ -0,0 +1,17 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
19
src/musikdroid/build.gradle
Normal file
19
src/musikdroid/build.gradle
Normal file
@ -0,0 +1,19 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
jcenter()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:2.2.3'
|
||||
classpath 'me.tatarka:gradle-retrolambda:3.5.0'
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
jcenter()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
17
src/musikdroid/gradle.properties
Normal file
17
src/musikdroid/gradle.properties
Normal file
@ -0,0 +1,17 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
BIN
src/musikdroid/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
src/musikdroid/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
src/musikdroid/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
src/musikdroid/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
#Mon Dec 28 10:00:20 PST 2015
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip
|
160
src/musikdroid/gradlew
vendored
Normal file
160
src/musikdroid/gradlew
vendored
Normal file
@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
90
src/musikdroid/gradlew.bat
vendored
Normal file
90
src/musikdroid/gradlew.bat
vendored
Normal file
@ -0,0 +1,90 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windowz variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
goto execute
|
||||
|
||||
:4NT_args
|
||||
@rem Get arguments from the 4NT Shell from JP Software
|
||||
set CMD_LINE_ARGS=%$
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
1
src/musikdroid/settings.gradle
Normal file
1
src/musikdroid/settings.gradle
Normal file
@ -0,0 +1 @@
|
||||
include ':app'
|
Loading…
x
Reference in New Issue
Block a user