Added musikdroid (promoted from an the musikcube-websockets external

repo).
This commit is contained in:
casey langen 2017-02-14 22:03:59 -08:00
parent 3512e14a2e
commit a367a0471e
62 changed files with 4589 additions and 0 deletions

8
src/musikdroid/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
*.iml
.gradle
/local.properties
.idea
.DS_Store
/build
/captures
.externalNativeBuild

1
src/musikdroid/app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View 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
View 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 *;
}

View File

@ -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());
}
}

View 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>

View File

@ -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();
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -0,0 +1,5 @@
package io.casey.musikcube.remote;
public interface Filterable {
void setFilter(final String filter);
}

View File

@ -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);
}
}
};
}

View File

@ -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();
}
}
}
};
}

View File

@ -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";
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}
}

View File

@ -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");
}
}
}

View File

@ -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();
});
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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();
}
};
}
}
}

View File

@ -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();
}
}
}
};
}

View File

@ -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);
}
}
}
}
};
}

View File

@ -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;
}
}

View File

@ -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() {
}
}

View File

@ -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();
}

View File

@ -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) { }
};
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View File

@ -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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View 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>

View 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>

View 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>

View 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">&lt; seek</string>
<string name="button_next">next</string>
<string name="button_seek_forward">seek &gt;</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>

View 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>

View 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>

View File

@ -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);
}
}

View 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
}

View 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

Binary file not shown.

View 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
View 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
View 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

View File

@ -0,0 +1 @@
include ':app'