Private
Public Access
Import minamp 1.0 source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0df8124629
194 files changed
+7319
No files matched your search
@@ -0,0 +1,183 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import android.app.Application;
|
||||
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.Build;
|
||||
import android.os.Handler;
|
||||
|
||||
import com.minamp.net.CertVerifier;
|
||||
import com.minamp.net.Http;
|
||||
import com.minamp.plex.Library;
|
||||
import com.minamp.plex.Plex;
|
||||
import com.minamp.plex.Server;
|
||||
import com.minamp.util.Json;
|
||||
|
||||
public class App extends Application {
|
||||
|
||||
private static App instance;
|
||||
public static App get() { return instance; }
|
||||
|
||||
public Handler handler;
|
||||
public SharedPreferences prefs;
|
||||
public Server server;
|
||||
public Library library;
|
||||
public ImageLoader images;
|
||||
|
||||
// quality: 0 = original (direct play when possible), otherwise MP3 kbps
|
||||
public static final int[] QUALITIES = {0, 320, 256, 192, 128, 96};
|
||||
public static final String[] QUALITY_LABELS = {"Original", "320 kbps MP3", "256 kbps MP3", "192 kbps MP3", "128 kbps MP3", "96 kbps MP3"};
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
instance = this;
|
||||
handler = new Handler();
|
||||
prefs = getSharedPreferences("minamp", MODE_PRIVATE);
|
||||
|
||||
// Android 2.2's HttpURLConnection pool is buggy; we use our own client, but be safe.
|
||||
System.setProperty("http.keepAlive", "false");
|
||||
|
||||
String cid = prefs.getString("clientId", null);
|
||||
if (cid == null) {
|
||||
cid = UUID.randomUUID().toString();
|
||||
prefs.edit().putString("clientId", cid).commit();
|
||||
}
|
||||
Plex.clientId = cid;
|
||||
Plex.platformVersion = Build.VERSION.RELEASE;
|
||||
Plex.device = Build.MODEL;
|
||||
Plex.deviceName = Build.MODEL;
|
||||
try { Plex.version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; } catch (Exception ignored) {}
|
||||
|
||||
CertVerifier.setRootStore(new AssetRoots(this));
|
||||
images = new ImageLoader(this);
|
||||
loadServer();
|
||||
PlayQueue.get().restore(this);
|
||||
|
||||
registerReceiver(new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context c, Intent i) {
|
||||
// Network changed (Wi-Fi <-> mobile, new Wi-Fi...): re-pick the best connection lazily.
|
||||
if (server != null) server.invalidate();
|
||||
}
|
||||
}, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ server/library
|
||||
|
||||
public void loadServer() {
|
||||
String sj = prefs.getString("server", null);
|
||||
String sec = prefs.getString("section", null);
|
||||
server = null;
|
||||
library = null;
|
||||
if (sj != null) {
|
||||
try {
|
||||
server = Server.fromJson(Json.parseObj(sj));
|
||||
if (sec != null) library = new Library(server, sec);
|
||||
} catch (Exception e) {
|
||||
server = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void saveServer(Server s) {
|
||||
prefs.edit().putString("server", s.toJson().toString()).remove("section").remove("sectionTitle").commit();
|
||||
server = s;
|
||||
library = null;
|
||||
}
|
||||
|
||||
public void saveSection(String key, String title) {
|
||||
prefs.edit().putString("section", key).putString("sectionTitle", title).commit();
|
||||
library = new Library(server, key);
|
||||
}
|
||||
|
||||
public boolean isConfigured() { return server != null && library != null; }
|
||||
|
||||
public String sectionTitle() { return prefs.getString("sectionTitle", "Music"); }
|
||||
|
||||
public void signOut() {
|
||||
prefs.edit().remove("server").remove("section").remove("sectionTitle").remove("account").remove("username").commit();
|
||||
server = null;
|
||||
library = null;
|
||||
Http.evictAll();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ quality
|
||||
|
||||
public boolean onMobileData() {
|
||||
try {
|
||||
ConnectivityManager cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
|
||||
NetworkInfo ni = cm.getActiveNetworkInfo();
|
||||
return ni != null && ni.getType() != ConnectivityManager.TYPE_WIFI;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public int qualityHome() { return prefs.getInt("qHome", 0); }
|
||||
public int qualityAway() { return prefs.getInt("qAway", 192); }
|
||||
|
||||
/** Max kbps for the current situation; 0 = original. */
|
||||
public int currentMaxKbps() {
|
||||
boolean away = server == null || !server.isLocal() || onMobileData();
|
||||
int q = away ? qualityAway() : qualityHome();
|
||||
if (server != null && server.isRelay() && (q == 0 || q > 192)) q = 192; // relay is bandwidth-capped
|
||||
return q;
|
||||
}
|
||||
|
||||
public boolean downloadFirst() { return prefs.getBoolean("downloadFirst", false); }
|
||||
|
||||
// ------------------------------------------------------------------ trust store
|
||||
|
||||
/** Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der */
|
||||
static final class AssetRoots implements CertVerifier.RootStore {
|
||||
private final Context ctx;
|
||||
private Set<String> names;
|
||||
|
||||
AssetRoots(Context c) { ctx = c; }
|
||||
|
||||
public synchronized List<byte[]> find(byte[] subjectDer) {
|
||||
try {
|
||||
if (names == null) {
|
||||
names = new HashSet<String>();
|
||||
String[] l = ctx.getAssets().list("roots");
|
||||
if (l != null) for (int i = 0; i < l.length; i++) names.add(l[i]);
|
||||
}
|
||||
String h = CertVerifier.sha1Hex(subjectDer);
|
||||
List<byte[]> out = null;
|
||||
for (int i = 0; names.contains(h + "-" + i + ".der"); i++) {
|
||||
if (out == null) out = new ArrayList<byte[]>();
|
||||
out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der")));
|
||||
}
|
||||
return out;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] readAll(InputStream in) throws IOException {
|
||||
try {
|
||||
ByteArrayOutputStream b = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) b.write(buf, 0, n);
|
||||
return b.toByteArray();
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.minamp.plex.Item;
|
||||
import com.minamp.plex.Library;
|
||||
import com.minamp.plex.Server;
|
||||
|
||||
/** Shared bits: background tasks, mini player, item actions, settings menu. */
|
||||
public abstract class BaseActivity extends Activity implements PlayerService.Listener, PlayQueue.Listener {
|
||||
|
||||
private static final ExecutorService POOL = Executors.newFixedThreadPool(2);
|
||||
|
||||
protected App app;
|
||||
protected boolean destroyed;
|
||||
protected boolean visible;
|
||||
private View mini;
|
||||
private ImageView miniArt;
|
||||
private TextView miniTitle, miniSub;
|
||||
private ImageButton miniPlay;
|
||||
private ProgressBar miniProgress;
|
||||
private Glyph miniPlayGlyph;
|
||||
private String miniThumb;
|
||||
|
||||
// ------------------------------------------------------------------ tasks
|
||||
|
||||
public abstract static class Task<T> {
|
||||
public abstract T work() throws Exception;
|
||||
public void done(T result) {}
|
||||
public void fail(Exception e) {}
|
||||
}
|
||||
|
||||
protected <T> void run(final Task<T> t) {
|
||||
POOL.execute(new Runnable() {
|
||||
public void run() {
|
||||
try {
|
||||
final T r = t.work();
|
||||
app.handler.post(new Runnable() { public void run() { if (!destroyed) t.done(r); } });
|
||||
} catch (final Exception e) {
|
||||
app.handler.post(new Runnable() { public void run() { if (!destroyed) t.fail(e); } });
|
||||
} catch (final OutOfMemoryError e) {
|
||||
app.images.clearMemory();
|
||||
app.handler.post(new Runnable() { public void run() { if (!destroyed) t.fail(new Exception("Out of memory")); } });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_SHORT).show(); }
|
||||
|
||||
protected static String msg(Exception e) { return PlayerService.message(e); }
|
||||
|
||||
// ------------------------------------------------------------------ lifecycle
|
||||
|
||||
@Override
|
||||
protected void onCreate(android.os.Bundle b) {
|
||||
super.onCreate(b);
|
||||
app = App.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
visible = true;
|
||||
PlayerService.addListener(this);
|
||||
PlayQueue.get().addListener(this);
|
||||
bindMini();
|
||||
app.handler.post(tick);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
visible = false;
|
||||
PlayerService.removeListener(this);
|
||||
PlayQueue.get().removeListener(this);
|
||||
app.handler.removeCallbacks(tick);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
destroyed = true;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private final Runnable tick = new Runnable() {
|
||||
public void run() {
|
||||
if (!visible) return;
|
||||
onTick();
|
||||
app.handler.postDelayed(this, 500);
|
||||
}
|
||||
};
|
||||
|
||||
protected void onTick() { updateMiniProgress(); }
|
||||
|
||||
public void onPlayerChanged() { updateMini(); }
|
||||
|
||||
public void onQueueChanged() { updateMini(); }
|
||||
|
||||
// ------------------------------------------------------------------ top bar
|
||||
|
||||
protected void setupTopBar(String title, String sub, boolean back) {
|
||||
TextView t = (TextView) findViewById(R.id.bar_title);
|
||||
if (t != null) t.setText(title);
|
||||
setBarSub(sub);
|
||||
ImageButton b = (ImageButton) findViewById(R.id.btn_back);
|
||||
if (b != null && back) {
|
||||
b.setVisibility(View.VISIBLE);
|
||||
Ui.icon(b, Glyph.BACK, getResources().getColor(R.color.text));
|
||||
b.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { finish(); } });
|
||||
}
|
||||
}
|
||||
|
||||
protected void setBarSub(String sub) {
|
||||
TextView s = (TextView) findViewById(R.id.bar_sub);
|
||||
if (s == null) return;
|
||||
s.setVisibility(sub == null || sub.length() == 0 ? View.GONE : View.VISIBLE);
|
||||
s.setText(sub);
|
||||
}
|
||||
|
||||
protected ImageButton barAction(int id, int glyph, View.OnClickListener l) {
|
||||
ImageButton b = (ImageButton) findViewById(id);
|
||||
b.setVisibility(View.VISIBLE);
|
||||
Ui.icon(b, glyph, getResources().getColor(R.color.text));
|
||||
b.setOnClickListener(l);
|
||||
return b;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ status overlay
|
||||
|
||||
protected void showStatus(boolean spinning, String text, final Runnable retry) {
|
||||
View box = findViewById(R.id.status_box);
|
||||
if (box == null) return;
|
||||
box.setVisibility(View.VISIBLE);
|
||||
findViewById(R.id.status_spin).setVisibility(spinning ? View.VISIBLE : View.GONE);
|
||||
((TextView) findViewById(R.id.status_text)).setText(text);
|
||||
View r = findViewById(R.id.status_retry);
|
||||
r.setVisibility(retry != null ? View.VISIBLE : View.GONE);
|
||||
if (retry != null) r.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { retry.run(); } });
|
||||
}
|
||||
|
||||
protected void hideStatus() {
|
||||
View box = findViewById(R.id.status_box);
|
||||
if (box != null) box.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ mini player
|
||||
|
||||
private void bindMini() {
|
||||
if (mini == null) {
|
||||
mini = findViewById(R.id.mini_root);
|
||||
if (mini == null) return;
|
||||
miniArt = (ImageView) findViewById(R.id.mini_art);
|
||||
miniTitle = (TextView) findViewById(R.id.mini_title);
|
||||
miniSub = (TextView) findViewById(R.id.mini_sub);
|
||||
miniPlay = (ImageButton) findViewById(R.id.mini_play);
|
||||
miniProgress = (ProgressBar) findViewById(R.id.mini_progress);
|
||||
miniPlayGlyph = Ui.glyph(this, Glyph.PLAY, getResources().getColor(R.color.text), 18);
|
||||
miniPlayGlyph.scale(1f);
|
||||
miniPlay.setImageDrawable(miniPlayGlyph);
|
||||
ImageButton next = (ImageButton) findViewById(R.id.mini_next);
|
||||
Ui.icon(next, Glyph.NEXT, getResources().getColor(R.color.text), 16);
|
||||
miniPlay.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { PlayerService.send(BaseActivity.this, PlayerService.ACTION_TOGGLE); }
|
||||
});
|
||||
next.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { PlayerService.send(BaseActivity.this, PlayerService.ACTION_NEXT); }
|
||||
});
|
||||
findViewById(R.id.mini_body).setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { startActivity(new Intent(BaseActivity.this, NowPlayingActivity.class)); }
|
||||
});
|
||||
}
|
||||
updateMini();
|
||||
}
|
||||
|
||||
private void updateMini() {
|
||||
if (mini == null) return;
|
||||
Item it = PlayQueue.get().current();
|
||||
if (it == null) { mini.setVisibility(View.GONE); return; }
|
||||
mini.setVisibility(View.VISIBLE);
|
||||
miniTitle.setText(it.title);
|
||||
String st = PlayerService.status();
|
||||
miniSub.setText(PlayerService.state() == PlayerService.BUFFERING && st.length() > 0 ? st : it.artist);
|
||||
miniPlayGlyph.setType(PlayerService.isPlaying() ? Glyph.PAUSE : Glyph.PLAY);
|
||||
if (!it.thumb.equals(miniThumb)) {
|
||||
miniThumb = it.thumb;
|
||||
app.images.load(miniArt, it.thumb, Ui.dp(this, 40), R.drawable.art_placeholder);
|
||||
}
|
||||
updateMiniProgress();
|
||||
}
|
||||
|
||||
private void updateMiniProgress() {
|
||||
if (mini == null || miniProgress == null) return;
|
||||
long d = PlayerService.duration();
|
||||
miniProgress.setProgress(d > 0 ? (int) (PlayerService.position() * 1000 / d) : 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ item actions
|
||||
|
||||
protected Library lib() { return app.library; }
|
||||
|
||||
/** Tap on an item in a list. `context` = the list it's in (tracks play from there). */
|
||||
protected void open(Item it, List<Item> contextTracks, int trackIndex, String sourceTitle) {
|
||||
switch (it.type) {
|
||||
case Item.ARTIST:
|
||||
case Item.ALBUM:
|
||||
case Item.PLAYLIST:
|
||||
startActivity(new Intent(this, BrowseActivity.class).putExtra(BrowseActivity.EXTRA_ITEM, it.serialize()));
|
||||
break;
|
||||
case Item.TRACK:
|
||||
List<Item> q = contextTracks;
|
||||
int idx = trackIndex;
|
||||
if (q == null || q.isEmpty()) { q = new ArrayList<Item>(); q.add(it); idx = 0; }
|
||||
playList(q, idx, false, sourceTitle);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected void playList(List<Item> tracks, int start, boolean shuffle, String title) {
|
||||
if (tracks.isEmpty()) { toast("Nothing to play"); return; }
|
||||
PlayQueue.get().set(tracks, start, shuffle, title);
|
||||
PlayerService.playCurrent(this, 0);
|
||||
}
|
||||
|
||||
/** Fetch the tracks of an album/artist/playlist, then play/queue them. */
|
||||
protected void playContainer(final Item it, final boolean shuffle, final int how) {
|
||||
final Library lib = lib();
|
||||
if (lib == null) return;
|
||||
toast(how == 0 ? (shuffle ? "Shuffling " : "Playing ") + it.title : "Adding " + it.title + "…");
|
||||
run(new Task<List<Item>>() {
|
||||
public List<Item> work() throws Exception { return lib.tracksOf(it); }
|
||||
public void done(List<Item> tracks) {
|
||||
if (tracks.isEmpty()) { toast("No tracks"); return; }
|
||||
boolean wasEmpty = PlayQueue.get().size() == 0;
|
||||
if (how == 1) { PlayQueue.get().playNext(tracks); toast("Playing next"); }
|
||||
else if (how == 2) { PlayQueue.get().append(tracks); toast("Added to queue"); }
|
||||
else { playList(tracks, shuffle ? -1 : 0, shuffle, it.title); return; }
|
||||
if (wasEmpty) PlayerService.playCurrent(BaseActivity.this, 0);
|
||||
}
|
||||
public void fail(Exception e) { toast(msg(e)); }
|
||||
});
|
||||
}
|
||||
|
||||
protected void runRadio(final String title, final Task<List<Item>> fetch) {
|
||||
toast("Starting " + title + "…");
|
||||
run(new Task<List<Item>>() {
|
||||
public List<Item> work() throws Exception { return fetch.work(); }
|
||||
public void done(List<Item> tracks) {
|
||||
if (tracks.isEmpty()) { toast("Nothing found for " + title); return; }
|
||||
playList(tracks, 0, false, title);
|
||||
}
|
||||
public void fail(Exception e) { toast(msg(e)); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Long-press menu for any item. */
|
||||
protected void itemMenu(final Item it) {
|
||||
final List<String> labels = new ArrayList<String>();
|
||||
final List<Integer> actions = new ArrayList<Integer>();
|
||||
if (it.type == Item.TRACK) {
|
||||
labels.add("Play next"); actions.add(1);
|
||||
labels.add("Add to queue"); actions.add(2);
|
||||
if (it.albumKey.length() > 0) { labels.add("Go to album"); actions.add(5); }
|
||||
if (it.artistKey.length() > 0) { labels.add("Go to artist"); actions.add(6); }
|
||||
labels.add(it.loved() ? "Unlove" : "Love"); actions.add(7);
|
||||
} else if (it.isContainer()) {
|
||||
labels.add("Play"); actions.add(0);
|
||||
labels.add(it.type == Item.ARTIST ? "Artist radio (shuffle)" : "Shuffle"); actions.add(3);
|
||||
labels.add("Play next"); actions.add(1);
|
||||
labels.add("Add to queue"); actions.add(2);
|
||||
if (it.type == Item.ALBUM && it.artistKey.length() > 0) { labels.add("Go to artist"); actions.add(6); }
|
||||
} else return;
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(it.title)
|
||||
.setItems(labels.toArray(new String[labels.size()]), new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int which) { itemAction(it, actions.get(which).intValue()); }
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
protected void itemAction(final Item it, int action) {
|
||||
switch (action) {
|
||||
case 0: playContainer(it, false, 0); break;
|
||||
case 3: playContainer(it, true, 0); break;
|
||||
case 1:
|
||||
case 2:
|
||||
if (it.type == Item.TRACK) {
|
||||
List<Item> one = new ArrayList<Item>();
|
||||
one.add(it);
|
||||
boolean wasEmpty = PlayQueue.get().size() == 0;
|
||||
if (action == 1) PlayQueue.get().playNext(one); else PlayQueue.get().append(one);
|
||||
if (wasEmpty) PlayerService.playCurrent(this, 0);
|
||||
toast(action == 1 ? "Playing next" : "Added to queue");
|
||||
} else {
|
||||
playContainer(it, false, action);
|
||||
}
|
||||
break;
|
||||
case 5: openKey(it.albumKey, Item.ALBUM, it.album); break;
|
||||
case 6: openKey(it.artistKey, Item.ARTIST, it.type == Item.ALBUM ? it.artist : it.artist); break;
|
||||
case 7: toggleLove(it, null); break;
|
||||
}
|
||||
}
|
||||
|
||||
protected void openKey(String ratingKey, int type, String title) {
|
||||
Item i = new Item();
|
||||
i.type = type;
|
||||
i.ratingKey = ratingKey;
|
||||
i.title = title;
|
||||
startActivity(new Intent(this, BrowseActivity.class).putExtra(BrowseActivity.EXTRA_ITEM, i.serialize()));
|
||||
}
|
||||
|
||||
protected void toggleLove(final Item it, final Runnable after) {
|
||||
final Library lib = lib();
|
||||
if (lib == null) return;
|
||||
final boolean love = !it.loved();
|
||||
final double old = it.userRating;
|
||||
it.userRating = love ? 10 : -1;
|
||||
Item cur = PlayQueue.get().current();
|
||||
if (cur != null && cur != it && cur.ratingKey.equals(it.ratingKey)) cur.userRating = it.userRating;
|
||||
if (after != null) after.run();
|
||||
run(new Task<Object>() {
|
||||
public Object work() throws Exception { lib.rate(it, love ? 10 : -1); return null; }
|
||||
public void done(Object o) { toast(love ? "Loved" : "Removed from loved"); }
|
||||
public void fail(Exception e) {
|
||||
it.userRating = old;
|
||||
if (after != null) after.run();
|
||||
toast("Couldn't save rating: " + msg(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ menu
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
menu.add(0, 1, 0, "Now playing");
|
||||
menu.add(0, 2, 0, "Search");
|
||||
menu.add(0, 3, 0, "Settings");
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case 1: startActivity(new Intent(this, NowPlayingActivity.class)); return true;
|
||||
case 2: startActivity(new Intent(this, SearchActivity.class)); return true;
|
||||
case 3: showSettings(); return true;
|
||||
}
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
protected void showSettings() {
|
||||
Server s = app.server;
|
||||
String conn = s == null || s.active() == null ? "not connected yet" : Server.kindLabel(s.active().kind)
|
||||
+ (s.active().uri.startsWith("https") ? " (secure)" : " (unencrypted)");
|
||||
final String[] rows = {
|
||||
"Quality at home: " + qualityLabel(app.qualityHome()),
|
||||
"Quality away / mobile data: " + qualityLabel(app.qualityAway()),
|
||||
"Download tracks before playing: " + (app.downloadFirst() ? "On" : "Off"),
|
||||
"Library: " + app.sectionTitle(),
|
||||
"Server: " + (s == null ? "-" : s.name),
|
||||
"Connection: " + conn,
|
||||
"Sign out",
|
||||
};
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Settings")
|
||||
.setItems(rows, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int which) {
|
||||
switch (which) {
|
||||
case 0: pickQuality("qHome", app.qualityHome(), "Quality at home"); break;
|
||||
case 1: pickQuality("qAway", app.qualityAway(), "Quality away / mobile"); break;
|
||||
case 2:
|
||||
app.prefs.edit().putBoolean("downloadFirst", !app.downloadFirst()).commit();
|
||||
toast("Download before playing: " + (app.downloadFirst() ? "on" : "off"));
|
||||
break;
|
||||
case 3: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break;
|
||||
case 4: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break;
|
||||
case 5:
|
||||
if (app.server != null) {
|
||||
app.server.invalidate();
|
||||
toast("Reconnecting…");
|
||||
final Server sv = app.server;
|
||||
run(new Task<Server.Conn>() {
|
||||
public Server.Conn work() throws Exception { return sv.pick(); }
|
||||
public void done(Server.Conn c) { toast("Connected: " + Server.kindLabel(c.kind)); onConnectionChanged(); }
|
||||
public void fail(Exception e) { toast(msg(e)); }
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 6: confirmSignOut(); break;
|
||||
}
|
||||
}
|
||||
}).show();
|
||||
}
|
||||
|
||||
protected void onConnectionChanged() {}
|
||||
|
||||
private static String qualityLabel(int kbps) {
|
||||
for (int i = 0; i < App.QUALITIES.length; i++) if (App.QUALITIES[i] == kbps) return App.QUALITY_LABELS[i];
|
||||
return kbps + " kbps";
|
||||
}
|
||||
|
||||
private void pickQuality(final String key, int current, String title) {
|
||||
int sel = 0;
|
||||
for (int i = 0; i < App.QUALITIES.length; i++) if (App.QUALITIES[i] == current) sel = i;
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setSingleChoiceItems(App.QUALITY_LABELS, sel, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int which) {
|
||||
app.prefs.edit().putInt(key, App.QUALITIES[which]).commit();
|
||||
d.dismiss();
|
||||
toast("Applies from the next track");
|
||||
}
|
||||
}).show();
|
||||
}
|
||||
|
||||
private void confirmSignOut() {
|
||||
new AlertDialog.Builder(this)
|
||||
.setMessage("Sign out and forget this server?")
|
||||
.setPositiveButton("Sign out", new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
PlayerService.send(BaseActivity.this, PlayerService.ACTION_STOP);
|
||||
PlayQueue.get().clear();
|
||||
app.signOut();
|
||||
startActivity(new Intent(BaseActivity.this, SetupActivity.class).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
|
||||
finish();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.minamp.plex.Item;
|
||||
import com.minamp.plex.Library;
|
||||
|
||||
/** Artist (albums), album (tracks) or playlist (tracks) page with Play / Shuffle header. */
|
||||
public class BrowseActivity extends BaseActivity {
|
||||
|
||||
public static final String EXTRA_ITEM = "item";
|
||||
|
||||
private Item item;
|
||||
private ListView list;
|
||||
private ItemAdapter adapter;
|
||||
private View header;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
if (app.library == null) { finish(); return; }
|
||||
setContentView(R.layout.activity_browse);
|
||||
try {
|
||||
item = Item.deserialize(getIntent().getStringExtra(EXTRA_ITEM));
|
||||
} catch (Exception e) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setupTopBar(item.title, kindLabel(), true);
|
||||
barAction(R.id.btn_action, Glyph.SEARCH, new View.OnClickListener() {
|
||||
public void onClick(View v) { startActivity(new android.content.Intent(BrowseActivity.this, SearchActivity.class)); }
|
||||
});
|
||||
|
||||
list = (ListView) findViewById(R.id.list);
|
||||
adapter = new ItemAdapter(this, item.type == Item.ALBUM ? ItemAdapter.MODE_ALBUM_TRACKS : ItemAdapter.MODE_DEFAULT);
|
||||
header = getLayoutInflater().inflate(R.layout.view_detail_header, list, false);
|
||||
list.addHeaderView(header, null, false);
|
||||
list.setAdapter(adapter);
|
||||
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
|
||||
public void onItemClick(AdapterView<?> p, View v, int pos, long id) {
|
||||
int i = pos - list.getHeaderViewsCount();
|
||||
if (i < 0 || i >= adapter.getCount()) return;
|
||||
Item it = adapter.getItem(i);
|
||||
open(it, adapter.tracks(), adapter.trackIndex(i), item.title);
|
||||
}
|
||||
});
|
||||
list.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
|
||||
public boolean onItemLongClick(AdapterView<?> p, View v, int pos, long id) {
|
||||
int i = pos - list.getHeaderViewsCount();
|
||||
if (i < 0 || i >= adapter.getCount()) return false;
|
||||
itemMenu(adapter.getItem(i));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
Button play = (Button) header.findViewById(R.id.detail_play);
|
||||
Button shuffle = (Button) header.findViewById(R.id.detail_shuffle);
|
||||
if (item.type == Item.ARTIST) { play.setText("Play all"); shuffle.setText("Artist radio"); }
|
||||
play.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { playAll(false); } });
|
||||
shuffle.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { playAll(true); } });
|
||||
|
||||
bindHeader();
|
||||
load();
|
||||
}
|
||||
|
||||
private String kindLabel() {
|
||||
switch (item.type) {
|
||||
case Item.ARTIST: return "Artist";
|
||||
case Item.ALBUM: return "Album";
|
||||
case Item.PLAYLIST: return "Playlist";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void bindHeader() {
|
||||
((TextView) header.findViewById(R.id.detail_title)).setText(item.title);
|
||||
TextView sub = (TextView) header.findViewById(R.id.detail_sub);
|
||||
sub.setText(item.type == Item.ALBUM ? item.artist : kindLabel());
|
||||
if (item.type == Item.ALBUM && item.artistKey.length() > 0) {
|
||||
sub.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { openKey(item.artistKey, Item.ARTIST, item.artist); }
|
||||
});
|
||||
}
|
||||
app.images.load((ImageView) header.findViewById(R.id.detail_art), item.thumb, Ui.dp(this, 104), R.drawable.art_placeholder);
|
||||
}
|
||||
|
||||
private void setMeta(List<Item> children) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (item.type == Item.ARTIST) {
|
||||
sb.append(children.size()).append(children.size() == 1 ? " album" : " albums");
|
||||
} else {
|
||||
long total = 0;
|
||||
int n = 0;
|
||||
for (int i = 0; i < children.size(); i++) if (children.get(i).type == Item.TRACK) { n++; total += children.get(i).duration; }
|
||||
if (item.year > 0) sb.append(item.year).append(" · ");
|
||||
sb.append(n).append(n == 1 ? " track" : " tracks");
|
||||
if (total > 0) sb.append(" · ").append(Ui.longDuration(total));
|
||||
}
|
||||
((TextView) header.findViewById(R.id.detail_meta)).setText(sb.toString());
|
||||
}
|
||||
|
||||
private void load() {
|
||||
final Library lib = lib();
|
||||
final Item target = item;
|
||||
showStatus(true, "Loading…", null);
|
||||
run(new Task<Object[]>() {
|
||||
public Object[] work() throws Exception {
|
||||
Item full = null;
|
||||
if (target.type != Item.PLAYLIST && (target.thumb.length() == 0 || (target.type == Item.ALBUM && target.artist.length() == 0))) {
|
||||
List<Item> l = lib.server.items("/library/metadata/" + target.ratingKey);
|
||||
if (!l.isEmpty()) full = l.get(0);
|
||||
}
|
||||
String path = Library.childrenPath(target);
|
||||
if (target.type == Item.PLAYLIST) path += "?X-Plex-Container-Start=0&X-Plex-Container-Size=1000";
|
||||
List<Item> children = lib.server.items(path);
|
||||
return new Object[] { full, children };
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
public void done(Object[] r) {
|
||||
hideStatus();
|
||||
Item full = (Item) r[0];
|
||||
if (full != null) {
|
||||
item.thumb = full.thumb; item.artist = full.artist; item.year = full.year;
|
||||
item.artistKey = full.artistKey; item.title = full.title;
|
||||
setupTopBar(item.title, kindLabel(), true);
|
||||
bindHeader();
|
||||
}
|
||||
List<Item> children = (List<Item>) r[1];
|
||||
adapter.setAlbumArtist(item.artist);
|
||||
adapter.set(children, -1);
|
||||
setMeta(children);
|
||||
if (children.isEmpty()) showStatus(false, "Nothing here", null);
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
showStatus(false, msg(e), new Runnable() { public void run() { load(); } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void playAll(boolean shuffle) {
|
||||
if (item.type == Item.ARTIST) {
|
||||
playContainer(item, shuffle, 0); // artist: all tracks via allLeaves
|
||||
return;
|
||||
}
|
||||
List<Item> tracks = adapter.tracks();
|
||||
if (tracks.isEmpty()) { playContainer(item, shuffle, 0); return; }
|
||||
playList(tracks, shuffle ? -1 : 0, shuffle, item.title);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onQueueChanged() {
|
||||
super.onQueueChanged();
|
||||
if (adapter != null) adapter.notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.minamp;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.ColorFilter;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
/** Resolution-independent icons drawn with paths (no PNG sets, crisp on ldpi). */
|
||||
public final class Glyph extends Drawable {
|
||||
|
||||
public static final int PLAY = 1, PAUSE = 2, NEXT = 3, PREV = 4, SHUFFLE = 5, REPEAT = 6, REPEAT_ONE = 7,
|
||||
HEART = 8, HEART_OUTLINE = 9, QUEUE = 10, SEARCH = 11, MOON = 12, RADIO = 13, BACK = 14, MORE = 15, NOTE = 16, CLOCK = 17;
|
||||
|
||||
private int type;
|
||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path path = new Path();
|
||||
private final int sizePx;
|
||||
private float scale = 0.5f; // glyph size relative to bounds
|
||||
|
||||
public Glyph(int type, int color, int sizePx) {
|
||||
this.type = type;
|
||||
this.sizePx = sizePx;
|
||||
fill.setColor(color);
|
||||
fill.setStyle(Paint.Style.FILL);
|
||||
stroke.setColor(color);
|
||||
stroke.setStyle(Paint.Style.STROKE);
|
||||
stroke.setStrokeCap(Paint.Cap.ROUND);
|
||||
stroke.setStrokeJoin(Paint.Join.ROUND);
|
||||
}
|
||||
|
||||
public Glyph scale(float s) { scale = s; invalidateSelf(); return this; }
|
||||
|
||||
public void setType(int t) { if (t != type) { type = t; invalidateSelf(); } }
|
||||
|
||||
public void setColor(int c) { fill.setColor(c); stroke.setColor(c); invalidateSelf(); }
|
||||
|
||||
@Override public int getIntrinsicWidth() { return sizePx; }
|
||||
@Override public int getIntrinsicHeight() { return sizePx; }
|
||||
|
||||
@Override
|
||||
public void draw(Canvas c) {
|
||||
Rect b = getBounds();
|
||||
float s = Math.min(b.width(), b.height()) * scale;
|
||||
float cx = b.exactCenterX(), cy = b.exactCenterY();
|
||||
float l = cx - s / 2, t = cy - s / 2, r = cx + s / 2, bt = cy + s / 2;
|
||||
stroke.setStrokeWidth(Math.max(1.5f, s * 0.11f));
|
||||
path.reset();
|
||||
switch (type) {
|
||||
case PLAY:
|
||||
path.moveTo(l + s * 0.15f, t);
|
||||
path.lineTo(r, cy);
|
||||
path.lineTo(l + s * 0.15f, bt);
|
||||
path.close();
|
||||
c.drawPath(path, fill);
|
||||
break;
|
||||
case PAUSE: {
|
||||
float w = s * 0.3f;
|
||||
c.drawRect(l + s * 0.08f, t, l + s * 0.08f + w, bt, fill);
|
||||
c.drawRect(r - s * 0.08f - w, t, r - s * 0.08f, bt, fill);
|
||||
break;
|
||||
}
|
||||
case NEXT:
|
||||
case PREV: {
|
||||
boolean next = type == NEXT;
|
||||
float bar = s * 0.13f;
|
||||
float tri = s - bar - s * 0.05f;
|
||||
if (next) {
|
||||
path.moveTo(l, t); path.lineTo(l + tri, cy); path.lineTo(l, bt); path.close();
|
||||
c.drawPath(path, fill);
|
||||
c.drawRect(r - bar, t, r, bt, fill);
|
||||
} else {
|
||||
path.moveTo(r, t); path.lineTo(r - tri, cy); path.lineTo(r, bt); path.close();
|
||||
c.drawPath(path, fill);
|
||||
c.drawRect(l, t, l + bar, bt, fill);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SHUFFLE: {
|
||||
float a = s * 0.22f;
|
||||
path.moveTo(l, t + s * 0.2f);
|
||||
path.cubicTo(cx, t + s * 0.2f, cx, bt - s * 0.2f, r - a * 0.4f, bt - s * 0.2f);
|
||||
path.moveTo(l, bt - s * 0.2f);
|
||||
path.cubicTo(cx, bt - s * 0.2f, cx, t + s * 0.2f, r - a * 0.4f, t + s * 0.2f);
|
||||
c.drawPath(path, stroke);
|
||||
arrowHead(c, r, t + s * 0.2f, a);
|
||||
arrowHead(c, r, bt - s * 0.2f, a);
|
||||
break;
|
||||
}
|
||||
case REPEAT:
|
||||
case REPEAT_ONE: {
|
||||
float a = s * 0.2f;
|
||||
RectF box = new RectF(l + s * 0.04f, t + s * 0.2f, r - s * 0.04f, bt - s * 0.2f);
|
||||
float rr = s * 0.15f;
|
||||
path.moveTo(box.left + rr * 2, box.top);
|
||||
path.lineTo(box.right - rr, box.top);
|
||||
path.quadTo(box.right, box.top, box.right, box.top + rr);
|
||||
path.lineTo(box.right, box.bottom - rr);
|
||||
path.quadTo(box.right, box.bottom, box.right - rr, box.bottom);
|
||||
path.lineTo(box.left + rr, box.bottom);
|
||||
path.quadTo(box.left, box.bottom, box.left, box.bottom - rr);
|
||||
path.lineTo(box.left, box.top + rr);
|
||||
path.quadTo(box.left, box.top, box.left + rr, box.top);
|
||||
c.drawPath(path, stroke);
|
||||
arrowHead(c, box.left + rr * 2 + a * 0.8f, box.top, a);
|
||||
if (type == REPEAT_ONE) {
|
||||
Paint tp = new Paint(fill);
|
||||
tp.setTextSize(s * 0.5f);
|
||||
tp.setFakeBoldText(true);
|
||||
tp.setTextAlign(Paint.Align.CENTER);
|
||||
c.drawText("1", cx, cy + s * 0.18f, tp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case HEART:
|
||||
case HEART_OUTLINE: {
|
||||
float w = s, h = s * 0.9f;
|
||||
float x = l, y = t + s * 0.08f;
|
||||
path.moveTo(x + w / 2, y + h);
|
||||
path.cubicTo(x - w * 0.15f, y + h * 0.45f, x + w * 0.05f, y - h * 0.15f, x + w / 2, y + h * 0.22f);
|
||||
path.cubicTo(x + w * 0.95f, y - h * 0.15f, x + w * 1.15f, y + h * 0.45f, x + w / 2, y + h);
|
||||
path.close();
|
||||
c.drawPath(path, type == HEART ? fill : stroke);
|
||||
break;
|
||||
}
|
||||
case QUEUE: {
|
||||
float gap = s * 0.3f;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
float y = t + s * 0.15f + i * gap;
|
||||
c.drawLine(l + (i == 2 ? 0 : 0), y, r - (i == 2 ? s * 0.35f : 0), y, stroke);
|
||||
}
|
||||
path.moveTo(r - s * 0.25f, bt - s * 0.25f);
|
||||
path.lineTo(r, bt - s * 0.08f);
|
||||
path.lineTo(r - s * 0.25f, bt + s * 0.09f);
|
||||
path.close();
|
||||
c.drawPath(path, fill);
|
||||
break;
|
||||
}
|
||||
case SEARCH: {
|
||||
float rad = s * 0.32f;
|
||||
c.drawCircle(l + rad + s * 0.05f, t + rad + s * 0.05f, rad, stroke);
|
||||
c.drawLine(l + rad * 1.75f + s * 0.05f, t + rad * 1.75f + s * 0.05f, r, bt, stroke);
|
||||
break;
|
||||
}
|
||||
case MOON: {
|
||||
Path p2 = new Path();
|
||||
path.addCircle(cx, cy, s * 0.45f, Path.Direction.CW);
|
||||
c.save();
|
||||
p2.addCircle(cx + s * 0.25f, cy - s * 0.2f, s * 0.38f, Path.Direction.CW);
|
||||
c.clipPath(p2, android.graphics.Region.Op.DIFFERENCE);
|
||||
c.drawPath(path, fill);
|
||||
c.restore();
|
||||
break;
|
||||
}
|
||||
case RADIO: {
|
||||
c.drawCircle(cx, cy, s * 0.1f, fill);
|
||||
RectF o = new RectF();
|
||||
for (int i = 1; i <= 2; i++) {
|
||||
float rr = s * 0.22f * i;
|
||||
o.set(cx - rr, cy - rr, cx + rr, cy + rr);
|
||||
c.drawArc(o, -45, 90, false, stroke);
|
||||
c.drawArc(o, 135, 90, false, stroke);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case BACK:
|
||||
path.moveTo(cx + s * 0.15f, t);
|
||||
path.lineTo(cx - s * 0.3f, cy);
|
||||
path.lineTo(cx + s * 0.15f, bt);
|
||||
c.drawPath(path, stroke);
|
||||
break;
|
||||
case MORE:
|
||||
for (int i = -1; i <= 1; i++) c.drawCircle(cx, cy + i * s * 0.35f, s * 0.1f, fill);
|
||||
break;
|
||||
case NOTE: {
|
||||
float rad = s * 0.18f;
|
||||
c.drawCircle(l + rad + s * 0.1f, bt - rad, rad, fill);
|
||||
c.drawLine(l + rad * 2 + s * 0.1f - stroke.getStrokeWidth() / 2, bt - rad, l + rad * 2 + s * 0.1f - stroke.getStrokeWidth() / 2, t, stroke);
|
||||
c.drawLine(l + rad * 2 + s * 0.1f, t, r - s * 0.1f, t + s * 0.2f, stroke);
|
||||
break;
|
||||
}
|
||||
case CLOCK:
|
||||
c.drawCircle(cx, cy, s * 0.45f, stroke);
|
||||
c.drawLine(cx, cy, cx, t + s * 0.18f, stroke);
|
||||
c.drawLine(cx, cy, cx + s * 0.22f, cy, stroke);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void arrowHead(Canvas c, float tipX, float y, float a) {
|
||||
Path p = new Path();
|
||||
p.moveTo(tipX, y);
|
||||
p.lineTo(tipX - a, y - a * 0.7f);
|
||||
p.lineTo(tipX - a, y + a * 0.7f);
|
||||
p.close();
|
||||
c.drawPath(p, fill);
|
||||
}
|
||||
|
||||
@Override public void setAlpha(int alpha) { fill.setAlpha(alpha); stroke.setAlpha(alpha); }
|
||||
@Override public void setColorFilter(ColorFilter cf) { fill.setColorFilter(cf); stroke.setColorFilter(cf); }
|
||||
@Override public int getOpacity() { return PixelFormat.TRANSLUCENT; }
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.HorizontalScrollView;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.minamp.plex.Item;
|
||||
import com.minamp.plex.Library;
|
||||
import com.minamp.plex.Server;
|
||||
import com.minamp.util.Json;
|
||||
|
||||
public class HomeActivity extends BaseActivity {
|
||||
|
||||
private static final int TAB_HOME = 0, TAB_ARTISTS = 1, TAB_ALBUMS = 2, TAB_PLAYLISTS = 3;
|
||||
private static final String[] ALBUM_SORTS = {"titleSort", "artist.titleSort", "addedAt:desc", "year:desc", "viewCount:desc"};
|
||||
private static final String[] ALBUM_SORT_LABELS = {"Title", "Artist", "Recently added", "Year (newest)", "Most played"};
|
||||
|
||||
private TextView[] tabs;
|
||||
private View tabLine;
|
||||
private ListView artistsList, albumsList, playlistsList;
|
||||
private ItemAdapter artists, albums, playlists;
|
||||
private LinearLayout homeContent;
|
||||
private int tab = -1;
|
||||
private boolean[] loaded = new boolean[4];
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
if (!app.isConfigured()) {
|
||||
startActivity(new Intent(this, SetupActivity.class));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setContentView(R.layout.activity_home);
|
||||
setupTopBar(app.sectionTitle(), connectionLine(), false);
|
||||
barAction(R.id.btn_action, Glyph.SEARCH, new View.OnClickListener() {
|
||||
public void onClick(View v) { startActivity(new Intent(HomeActivity.this, SearchActivity.class)); }
|
||||
});
|
||||
barAction(R.id.btn_action2, Glyph.MORE, new View.OnClickListener() {
|
||||
public void onClick(View v) { openOptionsMenu(); }
|
||||
});
|
||||
|
||||
tabs = new TextView[] {
|
||||
(TextView) findViewById(R.id.tab_home), (TextView) findViewById(R.id.tab_artists),
|
||||
(TextView) findViewById(R.id.tab_albums), (TextView) findViewById(R.id.tab_playlists)
|
||||
};
|
||||
tabLine = findViewById(R.id.tab_line);
|
||||
for (int i = 0; i < tabs.length; i++) {
|
||||
final int t = i;
|
||||
tabs[i].setOnClickListener(new View.OnClickListener() { public void onClick(View v) { showTab(t); } });
|
||||
}
|
||||
homeContent = (LinearLayout) findViewById(R.id.home_content);
|
||||
artistsList = (ListView) findViewById(R.id.list_artists);
|
||||
albumsList = (ListView) findViewById(R.id.list_albums);
|
||||
playlistsList = (ListView) findViewById(R.id.list_playlists);
|
||||
artists = new ItemAdapter(this, ItemAdapter.MODE_DEFAULT);
|
||||
albums = new ItemAdapter(this, ItemAdapter.MODE_DEFAULT);
|
||||
playlists = new ItemAdapter(this, ItemAdapter.MODE_DEFAULT);
|
||||
wire(artistsList, artists);
|
||||
wire(albumsList, albums);
|
||||
wire(playlistsList, playlists);
|
||||
artists.setPager(new ItemAdapter.Pager() { public void loadMore(int from) { loadArtists(from); } });
|
||||
albums.setPager(new ItemAdapter.Pager() { public void loadMore(int from) { loadAlbums(from); } });
|
||||
|
||||
Server.listener = new Server.Listener() {
|
||||
public void onConnectionChanged(Server s) {
|
||||
app.handler.post(new Runnable() { public void run() { if (!destroyed) setBarSub(connectionLine()); } });
|
||||
}
|
||||
};
|
||||
showTab(app.prefs.getInt("tab", TAB_HOME));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
// library or server may have changed in setup
|
||||
if (!app.isConfigured()) { startActivity(new Intent(this, SetupActivity.class)); finish(); return; }
|
||||
setupTopBar(app.sectionTitle(), connectionLine(), false);
|
||||
loaded = new boolean[4];
|
||||
int t = tab;
|
||||
tab = -1;
|
||||
showTab(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onConnectionChanged() { setBarSub(connectionLine()); }
|
||||
|
||||
private String connectionLine() {
|
||||
Server s = app.server;
|
||||
if (s == null) return "";
|
||||
Server.Conn c = s.active();
|
||||
return s.name + (c != null ? " · " + Server.kindLabel(c.kind) : "");
|
||||
}
|
||||
|
||||
private void wire(final ListView lv, final ItemAdapter ad) {
|
||||
lv.setAdapter(ad);
|
||||
lv.setOnItemClickListener(new AdapterView.OnItemClickListener() {
|
||||
public void onItemClick(AdapterView<?> p, View v, int pos, long id) { open(ad.getItem(pos), null, 0, null); }
|
||||
});
|
||||
lv.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
|
||||
public boolean onItemLongClick(AdapterView<?> p, View v, int pos, long id) { itemMenu(ad.getItem(pos)); return true; }
|
||||
});
|
||||
}
|
||||
|
||||
private void showTab(int t) {
|
||||
if (t == tab) return;
|
||||
tab = t;
|
||||
app.prefs.edit().putInt("tab", t).commit();
|
||||
int accent = getResources().getColor(R.color.accent), dim = getResources().getColor(R.color.text2);
|
||||
for (int i = 0; i < tabs.length; i++) tabs[i].setTextColor(i == t ? accent : dim);
|
||||
int w = getResources().getDisplayMetrics().widthPixels / 4;
|
||||
LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) tabLine.getLayoutParams();
|
||||
lp.width = w;
|
||||
lp.leftMargin = w * t;
|
||||
tabLine.setLayoutParams(lp);
|
||||
findViewById(R.id.home_scroll).setVisibility(t == TAB_HOME ? View.VISIBLE : View.GONE);
|
||||
artistsList.setVisibility(t == TAB_ARTISTS ? View.VISIBLE : View.GONE);
|
||||
albumsList.setVisibility(t == TAB_ALBUMS ? View.VISIBLE : View.GONE);
|
||||
playlistsList.setVisibility(t == TAB_PLAYLISTS ? View.VISIBLE : View.GONE);
|
||||
hideStatus();
|
||||
if (!loaded[t]) {
|
||||
loaded[t] = true;
|
||||
switch (t) {
|
||||
case TAB_HOME: buildHome(); break;
|
||||
case TAB_ARTISTS: loadArtists(0); break;
|
||||
case TAB_ALBUMS: loadAlbums(0); break;
|
||||
case TAB_PLAYLISTS: loadPlaylists(); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ home tab
|
||||
|
||||
private void buildHome() {
|
||||
homeContent.removeAllViews();
|
||||
LayoutInflater inf = getLayoutInflater();
|
||||
int pad = Ui.dp(this, 8);
|
||||
|
||||
LinearLayout row1 = tileRow();
|
||||
row1.addView(tile(inf, Glyph.RADIO, "Library radio", "Shuffle everything", new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
final Library lib = lib();
|
||||
runRadio("Library radio", new Task<List<Item>>() { public List<Item> work() throws Exception { return lib.libraryRadio(); } });
|
||||
}
|
||||
}));
|
||||
row1.addView(spacer());
|
||||
row1.addView(tile(inf, Glyph.HEART, "Loved radio", "Your top rated", new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
final Library lib = lib();
|
||||
runRadio("Loved radio", new Task<List<Item>>() { public List<Item> work() throws Exception { return lib.topRatedRadio(); } });
|
||||
}
|
||||
}));
|
||||
homeContent.addView(row1);
|
||||
|
||||
LinearLayout row2 = tileRow();
|
||||
row2.addView(tile(inf, Glyph.CLOCK, "Time travel", "Pick a decade", new View.OnClickListener() {
|
||||
public void onClick(View v) { timeTravel(); }
|
||||
}));
|
||||
row2.addView(spacer());
|
||||
row2.addView(tile(inf, Glyph.SHUFFLE, "Random album", "Feeling lucky", new View.OnClickListener() {
|
||||
public void onClick(View v) { randomAlbum(); }
|
||||
}));
|
||||
homeContent.addView(row2);
|
||||
|
||||
addShelf(inf, "Recently added", lib().recentlyAddedPath(20), ALBUM_SORTS[2]);
|
||||
addShelf(inf, "Recently played", lib().recentlyPlayedPath(20), null);
|
||||
homeContent.setPadding(0, pad / 2, 0, pad);
|
||||
}
|
||||
|
||||
private LinearLayout tileRow() {
|
||||
LinearLayout r = new LinearLayout(this);
|
||||
r.setOrientation(LinearLayout.HORIZONTAL);
|
||||
int p = Ui.dp(this, 8);
|
||||
r.setPadding(p, p / 2, p, p / 2);
|
||||
return r;
|
||||
}
|
||||
|
||||
private View spacer() {
|
||||
View v = new View(this);
|
||||
v.setLayoutParams(new LinearLayout.LayoutParams(Ui.dp(this, 8), 1));
|
||||
return v;
|
||||
}
|
||||
|
||||
private View tile(LayoutInflater inf, int glyph, String title, String sub, View.OnClickListener l) {
|
||||
View t = inf.inflate(R.layout.view_home_tile, null);
|
||||
t.setLayoutParams(new LinearLayout.LayoutParams(0, Ui.dp(this, 56), 1f));
|
||||
Ui.icon((ImageView) t.findViewById(R.id.tile_icon), glyph, getResources().getColor(R.color.accent), 22);
|
||||
((TextView) t.findViewById(R.id.tile_title)).setText(title);
|
||||
((TextView) t.findViewById(R.id.tile_sub)).setText(sub);
|
||||
t.setOnClickListener(l);
|
||||
return t;
|
||||
}
|
||||
|
||||
private void addShelf(LayoutInflater inf, final String title, final String path, final String seeAllSort) {
|
||||
LinearLayout head = new LinearLayout(this);
|
||||
head.setOrientation(LinearLayout.HORIZONTAL);
|
||||
TextView h = (TextView) inf.inflate(R.layout.row_header, null);
|
||||
h.setText(title.toUpperCase());
|
||||
head.addView(h, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||
if (seeAllSort != null) {
|
||||
TextView all = (TextView) inf.inflate(R.layout.row_header, null);
|
||||
all.setText("SEE ALL ›");
|
||||
all.setTextColor(getResources().getColor(R.color.text2));
|
||||
all.setPadding(Ui.dp(this, 10), Ui.dp(this, 12), Ui.dp(this, 10), Ui.dp(this, 4));
|
||||
all.setBackgroundResource(R.drawable.bg_pressable);
|
||||
all.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
app.prefs.edit().putInt("albumSort", 2).commit();
|
||||
loaded[TAB_ALBUMS] = false;
|
||||
showTab(TAB_ALBUMS);
|
||||
}
|
||||
});
|
||||
head.addView(all, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
}
|
||||
homeContent.addView(head);
|
||||
|
||||
HorizontalScrollView hs = new HorizontalScrollView(this);
|
||||
hs.setHorizontalScrollBarEnabled(false);
|
||||
hs.setHorizontalFadingEdgeEnabled(false);
|
||||
final LinearLayout strip = new LinearLayout(this);
|
||||
strip.setOrientation(LinearLayout.HORIZONTAL);
|
||||
strip.setPadding(Ui.dp(this, 6), 0, Ui.dp(this, 6), 0);
|
||||
hs.addView(strip);
|
||||
final TextView loading = new TextView(this);
|
||||
loading.setText("Loading…");
|
||||
loading.setTextColor(getResources().getColor(R.color.text2));
|
||||
loading.setTextSize(12);
|
||||
loading.setPadding(Ui.dp(this, 6), Ui.dp(this, 30), 0, Ui.dp(this, 30));
|
||||
strip.addView(loading);
|
||||
homeContent.addView(hs);
|
||||
|
||||
final Library lib = lib();
|
||||
run(new Task<List<Item>>() {
|
||||
public List<Item> work() throws Exception { return lib.server.items(path); }
|
||||
public void done(List<Item> items) {
|
||||
strip.removeAllViews();
|
||||
if (items.isEmpty()) { loading.setText("Nothing here yet"); strip.addView(loading); return; }
|
||||
LayoutInflater li = getLayoutInflater();
|
||||
int art = Ui.dp(HomeActivity.this, 88);
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
final Item it = items.get(i);
|
||||
View card = li.inflate(R.layout.view_album_card, strip, false);
|
||||
((TextView) card.findViewById(R.id.card_title)).setText(it.title);
|
||||
((TextView) card.findViewById(R.id.card_sub)).setText(it.artist);
|
||||
app.images.load((ImageView) card.findViewById(R.id.card_art), it.thumb, art, R.drawable.art_placeholder);
|
||||
card.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { open(it, null, 0, null); } });
|
||||
card.setOnLongClickListener(new View.OnLongClickListener() { public boolean onLongClick(View v) { itemMenu(it); return true; } });
|
||||
strip.addView(card);
|
||||
}
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
loading.setText("Couldn't load: " + msg(e) + " (tap to retry)");
|
||||
loading.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { loaded[TAB_HOME] = false; buildHome(); loaded[TAB_HOME] = true; }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void timeTravel() {
|
||||
final Library lib = lib();
|
||||
toast("Finding decades…");
|
||||
run(new Task<List<Integer>>() {
|
||||
public List<Integer> work() throws Exception { return lib.decades(); }
|
||||
public void done(final List<Integer> decades) {
|
||||
if (decades.isEmpty()) { toast("No release years in this library"); return; }
|
||||
String[] labels = new String[decades.size() + 1];
|
||||
labels[0] = "Surprise me";
|
||||
for (int i = 0; i < decades.size(); i++) labels[i + 1] = decades.get(i) + "s";
|
||||
new AlertDialog.Builder(HomeActivity.this).setTitle("Time travel radio")
|
||||
.setItems(labels, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int which) {
|
||||
final int dec = which == 0 ? decades.get(new Random().nextInt(decades.size())).intValue()
|
||||
: decades.get(which - 1).intValue();
|
||||
runRadio(dec + "s radio", new Task<List<Item>>() {
|
||||
public List<Item> work() throws Exception { return lib.timeTravelRadio(dec); }
|
||||
});
|
||||
}
|
||||
}).show();
|
||||
}
|
||||
public void fail(Exception e) { toast(msg(e)); }
|
||||
});
|
||||
}
|
||||
|
||||
private void randomAlbum() {
|
||||
final Library lib = lib();
|
||||
run(new Task<List<Item>>() {
|
||||
public List<Item> work() throws Exception { return lib.server.items(lib.albumsPath("random", 0, 1)); }
|
||||
public void done(List<Item> l) {
|
||||
if (l.isEmpty()) { toast("No albums"); return; }
|
||||
Item a = l.get(0);
|
||||
playContainer(a, false, 0);
|
||||
open(a, null, 0, null);
|
||||
}
|
||||
public void fail(Exception e) { toast(msg(e)); }
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ list tabs
|
||||
|
||||
private int totalOf(Json.Obj mc, int fallback) {
|
||||
if (mc == null) return fallback;
|
||||
return mc.optInt("totalSize", fallback);
|
||||
}
|
||||
|
||||
private void loadArtists(final int from) {
|
||||
final Library lib = lib();
|
||||
if (from == 0) showStatus(true, "Loading artists…", null);
|
||||
run(new Task<Object[]>() {
|
||||
public Object[] work() throws Exception {
|
||||
Json.Obj mc = lib.server.get(lib.artistsPath(from, 100));
|
||||
return new Object[] { Item.listFrom(mc), Integer.valueOf(totalOf(mc, -1)) };
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
public void done(Object[] r) {
|
||||
if (tab == TAB_ARTISTS) hideStatus();
|
||||
List<Item> l = (List<Item>) r[0];
|
||||
int total = ((Integer) r[1]).intValue();
|
||||
if (from == 0) artists.set(l, total); else artists.append(l, total);
|
||||
if (from == 0 && l.isEmpty() && tab == TAB_ARTISTS) showStatus(false, "No artists in this library", null);
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
artists.loadFailed();
|
||||
if (from == 0 && tab == TAB_ARTISTS) showStatus(false, msg(e), new Runnable() { public void run() { loadArtists(0); } });
|
||||
else toast(msg(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void loadAlbums(final int from) {
|
||||
final Library lib = lib();
|
||||
final String sort = ALBUM_SORTS[Math.min(app.prefs.getInt("albumSort", 0), ALBUM_SORTS.length - 1)];
|
||||
if (from == 0) showStatus(true, "Loading albums…", null);
|
||||
run(new Task<Object[]>() {
|
||||
public Object[] work() throws Exception {
|
||||
Json.Obj mc = lib.server.get(lib.albumsPath(sort, from, 100));
|
||||
return new Object[] { Item.listFrom(mc), Integer.valueOf(totalOf(mc, -1)) };
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
public void done(Object[] r) {
|
||||
if (tab == TAB_ALBUMS) hideStatus();
|
||||
List<Item> l = (List<Item>) r[0];
|
||||
int total = ((Integer) r[1]).intValue();
|
||||
if (from == 0) { albums.set(l, total); albumsList.setSelection(0); } else albums.append(l, total);
|
||||
if (from == 0 && l.isEmpty() && tab == TAB_ALBUMS) showStatus(false, "No albums in this library", null);
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
albums.loadFailed();
|
||||
if (from == 0 && tab == TAB_ALBUMS) showStatus(false, msg(e), new Runnable() { public void run() { loadAlbums(0); } });
|
||||
else toast(msg(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void loadPlaylists() {
|
||||
final Library lib = lib();
|
||||
showStatus(true, "Loading playlists…", null);
|
||||
run(new Task<List<Item>>() {
|
||||
public List<Item> work() throws Exception { return lib.server.items(lib.playlistsPath()); }
|
||||
public void done(List<Item> l) {
|
||||
if (tab == TAB_PLAYLISTS) hideStatus();
|
||||
List<Item> audio = new ArrayList<Item>();
|
||||
for (int i = 0; i < l.size(); i++) if (l.get(i).type == Item.PLAYLIST) audio.add(l.get(i));
|
||||
playlists.set(audio, -1);
|
||||
if (audio.isEmpty() && tab == TAB_PLAYLISTS) showStatus(false, "No music playlists yet", null);
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
if (tab == TAB_PLAYLISTS) showStatus(false, msg(e), new Runnable() { public void run() { loadPlaylists(); } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ menu
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
super.onCreateOptionsMenu(menu);
|
||||
menu.add(0, 10, 0, "Sort albums");
|
||||
menu.add(0, 11, 0, "Refresh");
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareOptionsMenu(Menu menu) {
|
||||
MenuItem s = menu.findItem(10);
|
||||
if (s != null) s.setVisible(tab == TAB_ALBUMS);
|
||||
return super.onPrepareOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
if (item.getItemId() == 10) {
|
||||
new AlertDialog.Builder(this).setTitle("Sort albums by")
|
||||
.setSingleChoiceItems(ALBUM_SORT_LABELS, app.prefs.getInt("albumSort", 0), new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int which) {
|
||||
d.dismiss();
|
||||
app.prefs.edit().putInt("albumSort", which).commit();
|
||||
loadAlbums(0);
|
||||
}
|
||||
}).show();
|
||||
return true;
|
||||
}
|
||||
if (item.getItemId() == 11) {
|
||||
loaded = new boolean[4];
|
||||
int t = tab;
|
||||
tab = -1;
|
||||
showTab(t);
|
||||
return true;
|
||||
}
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Environment;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import com.minamp.net.CertVerifier;
|
||||
import com.minamp.net.Http;
|
||||
|
||||
/**
|
||||
* Album-art loader for a phone with a 24-32 MB heap: Plex resizes art server-side, we decode as
|
||||
* RGB_565, keep a small LRU in memory and a capped cache on the SD card, newest request first.
|
||||
*/
|
||||
public final class ImageLoader {
|
||||
|
||||
public interface Callback { void onBitmap(Bitmap b); }
|
||||
|
||||
private final App app;
|
||||
private final LinkedHashMap<String, Bitmap> mem;
|
||||
private int memBytes;
|
||||
private final int memMax;
|
||||
private final ArrayList<Job> stack = new ArrayList<Job>();
|
||||
private File dir;
|
||||
|
||||
private static final class Job {
|
||||
String key, thumb; int size; ImageView view; Callback cb;
|
||||
}
|
||||
|
||||
ImageLoader(App app) {
|
||||
this.app = app;
|
||||
long max = Runtime.getRuntime().maxMemory();
|
||||
memMax = (int) Math.min(max / 8, 4 * 1024 * 1024);
|
||||
mem = new LinkedHashMap<String, Bitmap>(32, 0.75f, true);
|
||||
for (int i = 0; i < 2; i++) {
|
||||
Thread t = new Thread("img-" + i) {
|
||||
public void run() { loop(); }
|
||||
};
|
||||
t.setDaemon(true);
|
||||
t.setPriority(Thread.MIN_PRIORITY + 1);
|
||||
t.start();
|
||||
}
|
||||
}
|
||||
|
||||
private File dir() {
|
||||
if (dir != null) return dir;
|
||||
File base = null;
|
||||
try {
|
||||
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = app.getExternalCacheDir();
|
||||
} catch (Exception ignored) {}
|
||||
if (base == null) base = app.getCacheDir();
|
||||
dir = new File(base, "art");
|
||||
dir.mkdirs();
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Loads art into a view (recycling-safe: the view remembers the latest request). */
|
||||
public void load(ImageView v, String thumb, int sizePx, int placeholderRes) {
|
||||
String key = key(thumb, sizePx);
|
||||
v.setTag(R.id.tag_image, key);
|
||||
if (key == null) { v.setImageResource(placeholderRes); return; }
|
||||
Bitmap b = fromMem(key);
|
||||
if (b != null) { v.setImageBitmap(b); return; }
|
||||
v.setImageResource(placeholderRes);
|
||||
Job j = new Job();
|
||||
j.key = key; j.thumb = thumb; j.size = sizePx; j.view = v;
|
||||
enqueue(j);
|
||||
}
|
||||
|
||||
public void load(String thumb, int sizePx, Callback cb) {
|
||||
String key = key(thumb, sizePx);
|
||||
if (key == null) { cb.onBitmap(null); return; }
|
||||
Bitmap b = fromMem(key);
|
||||
if (b != null) { cb.onBitmap(b); return; }
|
||||
Job j = new Job();
|
||||
j.key = key; j.thumb = thumb; j.size = sizePx; j.cb = cb;
|
||||
enqueue(j);
|
||||
}
|
||||
|
||||
private static String key(String thumb, int size) {
|
||||
if (thumb == null || thumb.length() == 0) return null;
|
||||
return thumb + "@" + size;
|
||||
}
|
||||
|
||||
private Bitmap fromMem(String key) {
|
||||
synchronized (mem) { return mem.get(key); }
|
||||
}
|
||||
|
||||
private void enqueue(Job j) {
|
||||
synchronized (stack) {
|
||||
// drop older jobs for the same view
|
||||
if (j.view != null) {
|
||||
for (Iterator<Job> it = stack.iterator(); it.hasNext();) if (it.next().view == j.view) it.remove();
|
||||
}
|
||||
stack.add(j);
|
||||
if (stack.size() > 60) stack.remove(0);
|
||||
stack.notify();
|
||||
}
|
||||
}
|
||||
|
||||
private void loop() {
|
||||
while (true) {
|
||||
Job j;
|
||||
synchronized (stack) {
|
||||
while (stack.isEmpty()) {
|
||||
try { stack.wait(); } catch (InterruptedException e) { return; }
|
||||
}
|
||||
j = stack.remove(stack.size() - 1); // LIFO: what's on screen now wins
|
||||
}
|
||||
if (j.view != null && !j.key.equals(j.view.getTag(R.id.tag_image))) continue;
|
||||
Bitmap b = fromMem(j.key);
|
||||
if (b == null) {
|
||||
try { b = fetch(j); } catch (Throwable t) { b = null; }
|
||||
if (b != null) {
|
||||
synchronized (mem) {
|
||||
Bitmap old = mem.put(j.key, b);
|
||||
if (old != null) memBytes -= bytes(old);
|
||||
memBytes += bytes(b);
|
||||
Iterator<Map.Entry<String, Bitmap>> it = mem.entrySet().iterator();
|
||||
while (memBytes > memMax && it.hasNext()) {
|
||||
Map.Entry<String, Bitmap> e = it.next();
|
||||
memBytes -= bytes(e.getValue());
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
final Bitmap fb = b;
|
||||
final Job fj = j;
|
||||
app.handler.post(new Runnable() {
|
||||
public void run() {
|
||||
if (fj.view != null) {
|
||||
if (fb != null && fj.key.equals(fj.view.getTag(R.id.tag_image))) fj.view.setImageBitmap(fb);
|
||||
} else if (fj.cb != null) {
|
||||
fj.cb.onBitmap(fb);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static int bytes(Bitmap b) { return b.getRowBytes() * b.getHeight(); }
|
||||
|
||||
private Bitmap fetch(Job j) throws Exception {
|
||||
String name = CertVerifier.sha1Hex(((app.server == null ? "" : app.server.machineId) + j.key).getBytes("UTF-8")) + ".jpg";
|
||||
File f = new File(dir(), name);
|
||||
byte[] data = null;
|
||||
if (f.exists()) {
|
||||
try { data = App.readAll(new FileInputStream(f)); } catch (Exception e) { data = null; }
|
||||
}
|
||||
if (data == null) {
|
||||
if (app.server == null) return null;
|
||||
String url = app.server.imageUrl(j.thumb, j.size);
|
||||
Http.Response r = Http.get(url, null, 20000);
|
||||
if (r.code != 200) { r.close(); return null; }
|
||||
data = r.readAll();
|
||||
try {
|
||||
FileOutputStream out = new FileOutputStream(f);
|
||||
out.write(data);
|
||||
out.close();
|
||||
trimDisk();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
BitmapFactory.Options o = new BitmapFactory.Options();
|
||||
o.inPreferredConfig = Bitmap.Config.RGB_565;
|
||||
o.inDither = true;
|
||||
return BitmapFactory.decodeByteArray(data, 0, data.length, o);
|
||||
}
|
||||
|
||||
private int writes;
|
||||
|
||||
private void trimDisk() {
|
||||
if (++writes % 25 != 0) return;
|
||||
File[] files = dir().listFiles();
|
||||
if (files == null || files.length < 600) return;
|
||||
Arrays.sort(files, new Comparator<File>() {
|
||||
public int compare(File a, File b) {
|
||||
long d = a.lastModified() - b.lastModified();
|
||||
return d < 0 ? -1 : (d > 0 ? 1 : 0);
|
||||
}
|
||||
});
|
||||
for (int i = 0; i < files.length - 400; i++) files[i].delete();
|
||||
}
|
||||
|
||||
public void clearMemory() {
|
||||
synchronized (mem) { mem.clear(); memBytes = 0; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.BaseAdapter;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.minamp.plex.Item;
|
||||
|
||||
/** Rows for artists / albums / tracks / playlists, with section headers and paging. */
|
||||
public class ItemAdapter extends BaseAdapter {
|
||||
|
||||
public static final int MODE_DEFAULT = 0, MODE_ALBUM_TRACKS = 1, MODE_QUEUE = 2;
|
||||
|
||||
public interface Pager { void loadMore(int from); }
|
||||
|
||||
private final Context ctx;
|
||||
private final LayoutInflater inflater;
|
||||
private final List<Item> items = new ArrayList<Item>();
|
||||
private final int mode;
|
||||
private final int artPx;
|
||||
private final int accent, text;
|
||||
private int total = -1; // total available on the server, -1 = everything loaded
|
||||
private boolean loadingMore;
|
||||
private Pager pager;
|
||||
private String albumArtist = "";
|
||||
private int highlight = -1; // queue index highlight
|
||||
private final App app;
|
||||
|
||||
public ItemAdapter(Context c, int mode) {
|
||||
ctx = c;
|
||||
this.mode = mode;
|
||||
inflater = LayoutInflater.from(c);
|
||||
artPx = Ui.dp(c, 44);
|
||||
accent = c.getResources().getColor(R.color.accent);
|
||||
text = c.getResources().getColor(R.color.text);
|
||||
app = App.get();
|
||||
}
|
||||
|
||||
public void setPager(Pager p) { pager = p; }
|
||||
public void setAlbumArtist(String a) { albumArtist = a == null ? "" : a; }
|
||||
public void setHighlight(int i) { highlight = i; notifyDataSetChanged(); }
|
||||
|
||||
public void set(List<Item> list, int totalSize) {
|
||||
items.clear();
|
||||
items.addAll(list);
|
||||
total = totalSize;
|
||||
loadingMore = false;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void append(List<Item> list, int totalSize) {
|
||||
items.addAll(list);
|
||||
total = totalSize;
|
||||
loadingMore = false;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void loadFailed() { loadingMore = false; }
|
||||
|
||||
public List<Item> items() { return items; }
|
||||
|
||||
/** Only the tracks, and the index of position within them (for "play from here"). */
|
||||
public List<Item> tracks() {
|
||||
List<Item> t = new ArrayList<Item>();
|
||||
for (int i = 0; i < items.size(); i++) if (items.get(i).type == Item.TRACK) t.add(items.get(i));
|
||||
return t;
|
||||
}
|
||||
|
||||
public int trackIndex(int position) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < position && i < items.size(); i++) if (items.get(i).type == Item.TRACK) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override public int getCount() { return items.size(); }
|
||||
@Override public Item getItem(int i) { return items.get(i); }
|
||||
@Override public long getItemId(int i) { return i; }
|
||||
@Override public int getViewTypeCount() { return 2; }
|
||||
@Override public int getItemViewType(int i) { return items.get(i).type == Item.HEADER ? 1 : 0; }
|
||||
@Override public boolean areAllItemsEnabled() { return false; }
|
||||
@Override public boolean isEnabled(int i) { return items.get(i).type != Item.HEADER; }
|
||||
|
||||
private static final class Holder {
|
||||
ImageView art; TextView num, title, sub, right;
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView(int pos, View v, ViewGroup parent) {
|
||||
if (pager != null && !loadingMore && total > items.size() && pos >= items.size() - 15) {
|
||||
loadingMore = true;
|
||||
final int from = items.size();
|
||||
app.handler.post(new Runnable() { public void run() { pager.loadMore(from); } });
|
||||
}
|
||||
Item it = items.get(pos);
|
||||
if (it.type == Item.HEADER) {
|
||||
if (v == null) v = inflater.inflate(R.layout.row_header, parent, false);
|
||||
((TextView) v).setText(it.title.toUpperCase());
|
||||
return v;
|
||||
}
|
||||
Holder h;
|
||||
if (v == null) {
|
||||
v = inflater.inflate(R.layout.row_item, parent, false);
|
||||
h = new Holder();
|
||||
h.art = (ImageView) v.findViewById(R.id.row_art);
|
||||
h.num = (TextView) v.findViewById(R.id.row_num);
|
||||
h.title = (TextView) v.findViewById(R.id.row_title);
|
||||
h.sub = (TextView) v.findViewById(R.id.row_sub);
|
||||
h.right = (TextView) v.findViewById(R.id.row_right);
|
||||
v.setTag(h);
|
||||
} else {
|
||||
h = (Holder) v.getTag();
|
||||
}
|
||||
|
||||
h.title.setText(it.title);
|
||||
boolean numbered = mode == MODE_ALBUM_TRACKS && it.type == Item.TRACK;
|
||||
if (numbered) {
|
||||
h.art.setVisibility(View.GONE);
|
||||
h.art.setTag(R.id.tag_image, null);
|
||||
h.num.setVisibility(View.VISIBLE);
|
||||
h.num.setText(it.index > 0 ? String.valueOf(it.index) : "•");
|
||||
boolean showArtist = it.artist.length() > 0 && !it.artist.equals(albumArtist);
|
||||
h.sub.setVisibility(showArtist ? View.VISIBLE : View.GONE);
|
||||
h.sub.setText(it.artist);
|
||||
} else {
|
||||
h.art.setVisibility(View.VISIBLE);
|
||||
h.num.setVisibility(View.GONE);
|
||||
app.images.load(h.art, it.thumb, artPx, R.drawable.art_placeholder);
|
||||
String sub = it.subtitle();
|
||||
h.sub.setVisibility(sub.length() > 0 ? View.VISIBLE : View.GONE);
|
||||
h.sub.setText(sub);
|
||||
}
|
||||
|
||||
String right = "";
|
||||
if (it.type == Item.TRACK && it.duration > 0) right = Ui.time(it.duration);
|
||||
h.right.setText(right);
|
||||
|
||||
boolean current;
|
||||
if (mode == MODE_QUEUE) current = pos == highlight;
|
||||
else {
|
||||
Item playing = PlayQueue.get().current();
|
||||
current = playing != null && it.type == Item.TRACK && playing.ratingKey.equals(it.ratingKey);
|
||||
}
|
||||
h.title.setTextColor(current ? accent : text);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.minamp;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.view.KeyEvent;
|
||||
|
||||
/** Wired-headset / Bluetooth buttons. Headset hook: click = play/pause, double-click = next, triple = previous. */
|
||||
public class MediaButtonReceiver extends BroadcastReceiver {
|
||||
|
||||
private static long lastClick;
|
||||
private static int clicks;
|
||||
private static Runnable pending;
|
||||
|
||||
@Override
|
||||
public void onReceive(final Context context, Intent intent) {
|
||||
if (!Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) return;
|
||||
KeyEvent ev = (KeyEvent) intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
|
||||
if (ev == null) return;
|
||||
if (isOrderedBroadcast()) abortBroadcast();
|
||||
if (ev.getAction() != KeyEvent.ACTION_DOWN || ev.getRepeatCount() > 0) return;
|
||||
final Context app = context.getApplicationContext();
|
||||
switch (ev.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_HEADSETHOOK:
|
||||
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: {
|
||||
long now = System.currentTimeMillis();
|
||||
clicks = now - lastClick < 450 ? clicks + 1 : 1;
|
||||
lastClick = now;
|
||||
if (pending != null) App.get().handler.removeCallbacks(pending);
|
||||
final int n = clicks;
|
||||
pending = new Runnable() {
|
||||
public void run() {
|
||||
pending = null;
|
||||
if (n >= 3) PlayerService.send(app, PlayerService.ACTION_PREV);
|
||||
else if (n == 2) PlayerService.send(app, PlayerService.ACTION_NEXT);
|
||||
else PlayerService.send(app, PlayerService.ACTION_TOGGLE);
|
||||
}
|
||||
};
|
||||
App.get().handler.postDelayed(pending, 450);
|
||||
break;
|
||||
}
|
||||
case KeyEvent.KEYCODE_MEDIA_NEXT:
|
||||
PlayerService.send(app, PlayerService.ACTION_NEXT);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
|
||||
PlayerService.send(app, PlayerService.ACTION_PREV);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_MEDIA_STOP:
|
||||
PlayerService.send(app, PlayerService.ACTION_PAUSE);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.minamp;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.SeekBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.minamp.plex.Item;
|
||||
|
||||
public class NowPlayingActivity extends BaseActivity {
|
||||
|
||||
private ImageView art;
|
||||
private TextView title, sub, from, elapsed, remaining, status, sleepText, albumLink;
|
||||
private SeekBar seek;
|
||||
private ImageButton playBtn, shuffleBtn, repeatBtn, loveBtn;
|
||||
private Glyph playGlyph, shuffleGlyph, repeatGlyph, loveGlyph;
|
||||
private View root;
|
||||
private boolean dragging;
|
||||
private String shownThumb;
|
||||
private int white, dim, accent;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
setContentView(R.layout.activity_now_playing);
|
||||
white = getResources().getColor(R.color.text);
|
||||
dim = 0x99FFFFFF;
|
||||
accent = getResources().getColor(R.color.accent);
|
||||
|
||||
root = findViewById(R.id.np_root);
|
||||
art = (ImageView) findViewById(R.id.np_art);
|
||||
title = (TextView) findViewById(R.id.np_title);
|
||||
sub = (TextView) findViewById(R.id.np_sub);
|
||||
from = (TextView) findViewById(R.id.np_from);
|
||||
elapsed = (TextView) findViewById(R.id.np_elapsed);
|
||||
remaining = (TextView) findViewById(R.id.np_remaining);
|
||||
status = (TextView) findViewById(R.id.np_status);
|
||||
sleepText = (TextView) findViewById(R.id.np_sleep_text);
|
||||
albumLink = (TextView) findViewById(R.id.np_artist_link);
|
||||
seek = (SeekBar) findViewById(R.id.np_seek);
|
||||
playBtn = (ImageButton) findViewById(R.id.np_play);
|
||||
shuffleBtn = (ImageButton) findViewById(R.id.np_shuffle);
|
||||
repeatBtn = (ImageButton) findViewById(R.id.np_repeat);
|
||||
loveBtn = (ImageButton) findViewById(R.id.np_love);
|
||||
|
||||
ImageButton down = (ImageButton) findViewById(R.id.np_down);
|
||||
Ui.icon(down, Glyph.BACK, white);
|
||||
down.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { finish(); } });
|
||||
ImageButton queue = (ImageButton) findViewById(R.id.np_queue);
|
||||
Ui.icon(queue, Glyph.QUEUE, white);
|
||||
queue.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { startActivity(new Intent(NowPlayingActivity.this, QueueActivity.class)); }
|
||||
});
|
||||
|
||||
playGlyph = Ui.glyph(this, Glyph.PLAY, getResources().getColor(R.color.on_accent), 24);
|
||||
playGlyph.scale(1f);
|
||||
playBtn.setImageDrawable(playGlyph);
|
||||
playBtn.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { PlayerService.send(NowPlayingActivity.this, PlayerService.ACTION_TOGGLE); }
|
||||
});
|
||||
ImageButton prev = (ImageButton) findViewById(R.id.np_prev);
|
||||
ImageButton next = (ImageButton) findViewById(R.id.np_next);
|
||||
Ui.icon(prev, Glyph.PREV, white, 22);
|
||||
Ui.icon(next, Glyph.NEXT, white, 22);
|
||||
prev.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { PlayerService.send(NowPlayingActivity.this, PlayerService.ACTION_PREV); }
|
||||
});
|
||||
next.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { PlayerService.send(NowPlayingActivity.this, PlayerService.ACTION_NEXT); }
|
||||
});
|
||||
|
||||
shuffleGlyph = Ui.glyph(this, Glyph.SHUFFLE, dim, 20); shuffleGlyph.scale(1f);
|
||||
shuffleBtn.setImageDrawable(shuffleGlyph);
|
||||
shuffleBtn.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { PlayQueue.get().setShuffle(!PlayQueue.get().shuffle()); }
|
||||
});
|
||||
repeatGlyph = Ui.glyph(this, Glyph.REPEAT, dim, 20); repeatGlyph.scale(1f);
|
||||
repeatBtn.setImageDrawable(repeatGlyph);
|
||||
repeatBtn.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
PlayQueue.get().cycleRepeat();
|
||||
int r = PlayQueue.get().repeat();
|
||||
toast(r == PlayQueue.REPEAT_ALL ? "Repeat queue" : r == PlayQueue.REPEAT_ONE ? "Repeat track" : "Repeat off");
|
||||
}
|
||||
});
|
||||
loveGlyph = Ui.glyph(this, Glyph.HEART_OUTLINE, white, 20); loveGlyph.scale(1f);
|
||||
loveBtn.setImageDrawable(loveGlyph);
|
||||
loveBtn.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
Item it = PlayQueue.get().current();
|
||||
if (it != null) toggleLove(it, new Runnable() { public void run() { update(); } });
|
||||
}
|
||||
});
|
||||
|
||||
Ui.icon((ImageView) findViewById(R.id.np_sleep_icon), Glyph.MOON, 0xB3FFFFFF, 16);
|
||||
findViewById(R.id.np_sleep).setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { sleepDialog(); }
|
||||
});
|
||||
albumLink.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
Item it = PlayQueue.get().current();
|
||||
if (it != null && it.albumKey.length() > 0) openKey(it.albumKey, Item.ALBUM, it.album);
|
||||
}
|
||||
});
|
||||
art.setOnLongClickListener(new View.OnLongClickListener() {
|
||||
public boolean onLongClick(View v) {
|
||||
Item it = PlayQueue.get().current();
|
||||
if (it != null) itemMenu(it);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
|
||||
public void onProgressChanged(SeekBar s, int p, boolean user) {
|
||||
if (user) {
|
||||
long d = PlayerService.duration();
|
||||
elapsed.setText(Ui.time(d * p / 1000));
|
||||
}
|
||||
}
|
||||
public void onStartTrackingTouch(SeekBar s) { dragging = true; }
|
||||
public void onStopTrackingTouch(SeekBar s) {
|
||||
dragging = false;
|
||||
long d = PlayerService.duration();
|
||||
if (d > 0) PlayerService.seekTo(NowPlayingActivity.this, d * s.getProgress() / 1000);
|
||||
}
|
||||
});
|
||||
setBackdrop(0xFF1D1D22);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
update();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerChanged() { super.onPlayerChanged(); update(); }
|
||||
|
||||
@Override
|
||||
public void onQueueChanged() { super.onQueueChanged(); update(); }
|
||||
|
||||
@Override
|
||||
protected void onTick() { super.onTick(); updateTime(); }
|
||||
|
||||
private void update() {
|
||||
Item it = PlayQueue.get().current();
|
||||
PlayQueue q = PlayQueue.get();
|
||||
if (it == null) {
|
||||
title.setText("Nothing playing");
|
||||
sub.setText("Pick something from your library");
|
||||
from.setText("");
|
||||
art.setImageResource(R.drawable.art_placeholder);
|
||||
shownThumb = null;
|
||||
return;
|
||||
}
|
||||
title.setText(it.title);
|
||||
sub.setText(it.artist + (it.album.length() > 0 ? " — " + it.album : ""));
|
||||
from.setText(q.title().length() > 0 ? q.title() : it.album);
|
||||
playGlyph.setType(PlayerService.isPlaying() ? Glyph.PAUSE : Glyph.PLAY);
|
||||
shuffleGlyph.setColor(q.shuffle() ? accent : dim);
|
||||
repeatGlyph.setType(q.repeat() == PlayQueue.REPEAT_ONE ? Glyph.REPEAT_ONE : Glyph.REPEAT);
|
||||
repeatGlyph.setColor(q.repeat() != PlayQueue.REPEAT_OFF ? accent : dim);
|
||||
loveGlyph.setType(it.loved() ? Glyph.HEART : Glyph.HEART_OUTLINE);
|
||||
loveGlyph.setColor(it.loved() ? accent : white);
|
||||
albumLink.setVisibility(it.albumKey.length() > 0 ? View.VISIBLE : View.INVISIBLE);
|
||||
albumLink.setText("Album ›");
|
||||
|
||||
if (!it.thumb.equals(shownThumb)) {
|
||||
shownThumb = it.thumb;
|
||||
final String want = it.thumb;
|
||||
int size = Math.min(getResources().getDisplayMetrics().widthPixels, 480);
|
||||
art.setImageResource(R.drawable.art_placeholder);
|
||||
app.images.load(it.thumb, size, new ImageLoader.Callback() {
|
||||
public void onBitmap(Bitmap bm) {
|
||||
if (destroyed || !want.equals(shownThumb)) return;
|
||||
if (bm != null) { art.setImageBitmap(bm); setBackdrop(Ui.backdropColor(bm)); }
|
||||
}
|
||||
});
|
||||
}
|
||||
updateSleep();
|
||||
updateTime();
|
||||
}
|
||||
|
||||
private void updateTime() {
|
||||
long d = PlayerService.duration();
|
||||
long p = PlayerService.position();
|
||||
if (!dragging) {
|
||||
seek.setProgress(d > 0 ? (int) (p * 1000 / d) : 0);
|
||||
elapsed.setText(Ui.time(p));
|
||||
}
|
||||
remaining.setText("-" + Ui.time(Math.max(0, d - p)));
|
||||
String st = PlayerService.status();
|
||||
status.setText(st.length() > 0 ? st : PlayerService.sourceLabel());
|
||||
if (PlayerService.sleepAt() > 0) updateSleep();
|
||||
}
|
||||
|
||||
private void updateSleep() {
|
||||
long at = PlayerService.sleepAt();
|
||||
if (PlayerService.sleepEndOfTrack()) sleepText.setText("End of track");
|
||||
else if (at > 0) sleepText.setText(Ui.time(at - System.currentTimeMillis()));
|
||||
else sleepText.setText("Sleep");
|
||||
}
|
||||
|
||||
private void setBackdrop(int color) {
|
||||
GradientDrawable g = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM,
|
||||
new int[] { color, getResources().getColor(R.color.bg) });
|
||||
root.setBackgroundDrawable(g);
|
||||
}
|
||||
|
||||
private void sleepDialog() {
|
||||
final int[] mins = {0, 15, 30, 45, 60, 90, -1};
|
||||
String[] labels = {"Off", "15 minutes", "30 minutes", "45 minutes", "1 hour", "1½ hours", "End of this track"};
|
||||
new AlertDialog.Builder(this).setTitle("Sleep timer")
|
||||
.setItems(labels, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int which) {
|
||||
startService(new Intent(NowPlayingActivity.this, PlayerService.class)
|
||||
.setAction(PlayerService.ACTION_SLEEP).putExtra(PlayerService.EXTRA_MINUTES, mins[which]));
|
||||
app.handler.postDelayed(new Runnable() { public void run() { if (!destroyed) updateSleep(); } }, 300);
|
||||
}
|
||||
}).show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.minamp.plex.Item;
|
||||
import com.minamp.util.Json;
|
||||
|
||||
/** The play queue (main-thread only), with shuffle/repeat and persistence across restarts. */
|
||||
public final class PlayQueue {
|
||||
|
||||
public static final int REPEAT_OFF = 0, REPEAT_ALL = 1, REPEAT_ONE = 2;
|
||||
|
||||
private static final PlayQueue INSTANCE = new PlayQueue();
|
||||
public static PlayQueue get() { return INSTANCE; }
|
||||
|
||||
private final ArrayList<Item> items = new ArrayList<Item>();
|
||||
private ArrayList<Item> unshuffled;
|
||||
private int index = -1;
|
||||
private boolean shuffle;
|
||||
private int repeat;
|
||||
private String title = "";
|
||||
public long resumePositionMs; // restored position of the current track
|
||||
|
||||
public interface Listener { void onQueueChanged(); }
|
||||
private final ArrayList<Listener> listeners = new ArrayList<Listener>();
|
||||
|
||||
private PlayQueue() {}
|
||||
|
||||
public void addListener(Listener l) { if (!listeners.contains(l)) listeners.add(l); }
|
||||
public void removeListener(Listener l) { listeners.remove(l); }
|
||||
|
||||
private void changed() {
|
||||
for (int i = listeners.size() - 1; i >= 0; i--) listeners.get(i).onQueueChanged();
|
||||
save();
|
||||
}
|
||||
|
||||
public int size() { return items.size(); }
|
||||
public Item at(int i) { return items.get(i); }
|
||||
public int index() { return index; }
|
||||
public boolean shuffle() { return shuffle; }
|
||||
public int repeat() { return repeat; }
|
||||
public String title() { return title; }
|
||||
public List<Item> items() { return Collections.unmodifiableList(items); }
|
||||
|
||||
public Item current() {
|
||||
return index >= 0 && index < items.size() ? items.get(index) : null;
|
||||
}
|
||||
|
||||
public Item peekNext() {
|
||||
if (items.isEmpty()) return null;
|
||||
if (index + 1 < items.size()) return items.get(index + 1);
|
||||
return repeat == REPEAT_ALL ? items.get(0) : null;
|
||||
}
|
||||
|
||||
/** Replace the queue. If shuffling, the chosen start track plays first. */
|
||||
public void set(List<Item> list, int start, boolean shuffled, String sourceTitle) {
|
||||
items.clear();
|
||||
items.addAll(list);
|
||||
unshuffled = null;
|
||||
title = sourceTitle == null ? "" : sourceTitle;
|
||||
index = items.isEmpty() ? -1 : Math.max(0, Math.min(start, items.size() - 1));
|
||||
shuffle = false;
|
||||
resumePositionMs = 0;
|
||||
if (shuffled) {
|
||||
if (start < 0) index = items.isEmpty() ? -1 : new Random().nextInt(items.size());
|
||||
setShuffle(true);
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
public void setShuffle(boolean on) {
|
||||
if (on == shuffle) return;
|
||||
Item cur = current();
|
||||
if (on) {
|
||||
unshuffled = new ArrayList<Item>(items);
|
||||
if (cur != null) items.remove(index);
|
||||
Collections.shuffle(items, new Random());
|
||||
if (cur != null) items.add(0, cur);
|
||||
index = cur == null ? -1 : 0;
|
||||
} else if (unshuffled != null) {
|
||||
// keep anything added while shuffled
|
||||
ArrayList<Item> restored = new ArrayList<Item>(unshuffled);
|
||||
for (int i = 0; i < items.size(); i++) if (!containsRef(restored, items.get(i))) restored.add(items.get(i));
|
||||
for (int i = restored.size() - 1; i >= 0; i--) if (!containsRef(items, restored.get(i))) restored.remove(i);
|
||||
items.clear();
|
||||
items.addAll(restored);
|
||||
index = cur == null ? -1 : indexOfRef(items, cur);
|
||||
unshuffled = null;
|
||||
}
|
||||
shuffle = on;
|
||||
changed();
|
||||
}
|
||||
|
||||
private static boolean containsRef(List<Item> l, Item it) { return indexOfRef(l, it) >= 0; }
|
||||
|
||||
private static int indexOfRef(List<Item> l, Item it) {
|
||||
for (int i = 0; i < l.size(); i++) if (l.get(i) == it) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public void cycleRepeat() {
|
||||
repeat = (repeat + 1) % 3;
|
||||
changed();
|
||||
}
|
||||
|
||||
/** @return false when the end of the queue was reached. */
|
||||
public boolean advance(boolean userSkip) {
|
||||
if (items.isEmpty()) return false;
|
||||
if (repeat == REPEAT_ONE && !userSkip) { resumePositionMs = 0; changed(); return true; }
|
||||
if (index + 1 < items.size()) { index++; }
|
||||
else if (repeat == REPEAT_ALL) index = 0;
|
||||
else return false;
|
||||
resumePositionMs = 0;
|
||||
changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean back() {
|
||||
if (items.isEmpty()) return false;
|
||||
if (index > 0) index--;
|
||||
else if (repeat == REPEAT_ALL) index = items.size() - 1;
|
||||
else index = 0;
|
||||
resumePositionMs = 0;
|
||||
changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void jump(int i) {
|
||||
if (i < 0 || i >= items.size()) return;
|
||||
index = i;
|
||||
resumePositionMs = 0;
|
||||
changed();
|
||||
}
|
||||
|
||||
public void playNext(List<Item> list) {
|
||||
if (items.isEmpty()) { set(list, 0, false, "Queue"); return; }
|
||||
items.addAll(index + 1, list);
|
||||
if (unshuffled != null) unshuffled.addAll(list);
|
||||
changed();
|
||||
}
|
||||
|
||||
public void append(List<Item> list) {
|
||||
if (items.isEmpty()) { set(list, 0, false, "Queue"); return; }
|
||||
items.addAll(list);
|
||||
if (unshuffled != null) unshuffled.addAll(list);
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Move a queued item to play right after the current one. */
|
||||
public void moveNext(int i) {
|
||||
if (i < 0 || i >= items.size() || i == index) return;
|
||||
Item it = items.remove(i);
|
||||
if (i < index) index--;
|
||||
items.add(index + 1, it);
|
||||
changed();
|
||||
}
|
||||
|
||||
public void remove(int i) {
|
||||
if (i < 0 || i >= items.size()) return;
|
||||
Item it = items.remove(i);
|
||||
if (unshuffled != null) { int k = indexOfRef(unshuffled, it); if (k >= 0) unshuffled.remove(k); }
|
||||
if (i < index) index--;
|
||||
else if (i == index && index >= items.size()) index = items.size() - 1;
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Drop everything after the current track. */
|
||||
public void clearUpcoming() {
|
||||
if (index < 0) { clear(); return; }
|
||||
while (items.size() > index + 1) {
|
||||
Item it = items.remove(items.size() - 1);
|
||||
if (unshuffled != null) { int k = indexOfRef(unshuffled, it); if (k >= 0) unshuffled.remove(k); }
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
items.clear();
|
||||
unshuffled = null;
|
||||
index = -1;
|
||||
changed();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ persistence
|
||||
|
||||
private Context ctx;
|
||||
private boolean saving;
|
||||
|
||||
public void restore(Context c) {
|
||||
ctx = c.getApplicationContext();
|
||||
try {
|
||||
File f = new File(ctx.getFilesDir(), "queue.json");
|
||||
if (!f.exists()) return;
|
||||
Json.Obj o = Json.parseObj(new String(App.readAll(new FileInputStream(f)), "UTF-8"));
|
||||
Json.Arr a = o.optArr("items");
|
||||
items.clear();
|
||||
if (a != null) for (int i = 0; i < a.size(); i++) { Json.Obj io = a.optObj(i); if (io != null) items.add(Item.fromJson(io)); }
|
||||
index = Math.min(o.optInt("index", -1), items.size() - 1);
|
||||
repeat = o.optInt("repeat", 0);
|
||||
shuffle = o.optBool("shuffle");
|
||||
title = o.optString("title");
|
||||
resumePositionMs = o.optLong("pos", 0);
|
||||
} catch (Exception e) {
|
||||
items.clear();
|
||||
index = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remember position for "resume where I left off". */
|
||||
public void savePosition(long ms) {
|
||||
resumePositionMs = ms;
|
||||
save();
|
||||
}
|
||||
|
||||
public void save() {
|
||||
if (ctx == null || saving) return;
|
||||
saving = true;
|
||||
// serialise on the main thread (cheap), write on a background thread
|
||||
Json.Obj o = new Json.Obj();
|
||||
Json.Arr a = new Json.Arr();
|
||||
int max = Math.min(items.size(), 1000);
|
||||
for (int i = 0; i < max; i++) a.add(items.get(i).toJson());
|
||||
o.put("items", a).put("index", Long.valueOf(index)).put("repeat", Long.valueOf(repeat))
|
||||
.put("shuffle", Boolean.valueOf(shuffle)).put("title", title).put("pos", Long.valueOf(resumePositionMs));
|
||||
final String data = o.toString();
|
||||
saving = false;
|
||||
new Thread("save-queue") {
|
||||
public void run() {
|
||||
synchronized (PlayQueue.class) {
|
||||
try {
|
||||
File tmp = new File(ctx.getFilesDir(), "queue.tmp");
|
||||
FileOutputStream out = new FileOutputStream(tmp);
|
||||
out.write(data.getBytes("UTF-8"));
|
||||
out.close();
|
||||
tmp.renameTo(new File(ctx.getFilesDir(), "queue.json"));
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.media.AudioManager;
|
||||
import android.media.MediaPlayer;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.os.Environment;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
import android.telephony.PhoneStateListener;
|
||||
import android.telephony.TelephonyManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.minamp.net.Http;
|
||||
import com.minamp.net.StreamProxy;
|
||||
import com.minamp.plex.Item;
|
||||
import com.minamp.plex.Library;
|
||||
import com.minamp.plex.Plex;
|
||||
|
||||
/**
|
||||
* Foreground playback service: one MediaPlayer per track, fed through the loopback
|
||||
* {@link StreamProxy}. Handles audio focus, calls, headset buttons/unplug, Plex timeline
|
||||
* reporting, retries after network changes, and the sleep timer.
|
||||
*/
|
||||
public class PlayerService extends Service implements MediaPlayer.OnPreparedListener,
|
||||
MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, AudioManager.OnAudioFocusChangeListener {
|
||||
|
||||
public static final String ACTION_PLAY_CURRENT = "com.minamp.PLAY_CURRENT";
|
||||
public static final String ACTION_TOGGLE = "com.minamp.TOGGLE";
|
||||
public static final String ACTION_PLAY = "com.minamp.PLAY";
|
||||
public static final String ACTION_PAUSE = "com.minamp.PAUSE";
|
||||
public static final String ACTION_NEXT = "com.minamp.NEXT";
|
||||
public static final String ACTION_PREV = "com.minamp.PREV";
|
||||
public static final String ACTION_SEEK = "com.minamp.SEEK";
|
||||
public static final String ACTION_STOP = "com.minamp.STOP";
|
||||
public static final String ACTION_SLEEP = "com.minamp.SLEEP";
|
||||
public static final String EXTRA_POS = "pos";
|
||||
public static final String EXTRA_MINUTES = "minutes";
|
||||
|
||||
public static final int IDLE = 0, BUFFERING = 1, PLAYING = 2, PAUSED = 3;
|
||||
|
||||
// ---- state shared with the UI (main thread) ----
|
||||
private static PlayerService instance;
|
||||
private static int state = IDLE;
|
||||
private static String status = "";
|
||||
private static long sleepAt;
|
||||
private static boolean sleepEndOfTrack;
|
||||
|
||||
public interface Listener { void onPlayerChanged(); }
|
||||
private static final ArrayList<Listener> listeners = new ArrayList<Listener>();
|
||||
|
||||
public static void addListener(Listener l) { if (!listeners.contains(l)) listeners.add(l); }
|
||||
public static void removeListener(Listener l) { listeners.remove(l); }
|
||||
|
||||
public static int state() { return state; }
|
||||
public static String status() { return status; }
|
||||
public static boolean isPlaying() { return state == PLAYING || state == BUFFERING; }
|
||||
public static long sleepAt() { return sleepAt; }
|
||||
public static boolean sleepEndOfTrack() { return sleepEndOfTrack; }
|
||||
|
||||
public static long position() {
|
||||
PlayerService s = instance;
|
||||
if (s != null) return s.currentPosition();
|
||||
return PlayQueue.get().resumePositionMs;
|
||||
}
|
||||
|
||||
public static long duration() {
|
||||
Item it = PlayQueue.get().current();
|
||||
return it == null ? 0 : it.duration;
|
||||
}
|
||||
|
||||
/** Human-readable "where is this streaming from" for the Now Playing screen. */
|
||||
public static String sourceLabel() {
|
||||
PlayerService s = instance;
|
||||
if (s == null || s.track == null) return "";
|
||||
App app = App.get();
|
||||
String where = app.server == null || app.server.active() == null ? "" : com.minamp.plex.Server.kindLabel(app.server.active().kind);
|
||||
String q = s.transcoding ? s.kbps + " kbps MP3" : (s.track.codec.toUpperCase() + (s.track.bitrate > 0 ? " " + s.track.bitrate + " kbps" : ""));
|
||||
return where.length() > 0 ? where + " · " + q : q;
|
||||
}
|
||||
|
||||
public static void send(Context c, String action) {
|
||||
c.startService(new Intent(c, PlayerService.class).setAction(action));
|
||||
}
|
||||
|
||||
public static void playCurrent(Context c, long posMs) {
|
||||
c.startService(new Intent(c, PlayerService.class).setAction(ACTION_PLAY_CURRENT).putExtra(EXTRA_POS, posMs));
|
||||
}
|
||||
|
||||
public static void seekTo(Context c, long posMs) {
|
||||
c.startService(new Intent(c, PlayerService.class).setAction(ACTION_SEEK).putExtra(EXTRA_POS, posMs));
|
||||
}
|
||||
|
||||
// ---- instance ----
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
private final ExecutorService reporter = Executors.newSingleThreadExecutor();
|
||||
private StreamProxy proxy;
|
||||
private MediaPlayer mp;
|
||||
private boolean prepared;
|
||||
private int gen;
|
||||
private Item track;
|
||||
private long offsetMs; // transcodes restart at an offset; MediaPlayer counts from 0
|
||||
private long pendingPos; // position to show / seek to before prepared
|
||||
private boolean playWhenReady;
|
||||
private boolean transcoding;
|
||||
private boolean fromFile;
|
||||
private int kbps;
|
||||
private String session;
|
||||
private String routeId;
|
||||
private int retries;
|
||||
private int failuresInRow;
|
||||
private boolean forceDownload;
|
||||
private boolean scrobbled;
|
||||
private long lastTimeline;
|
||||
private long lastSave;
|
||||
private long lastPos; // last position seen while playing (getCurrentPosition is unreliable after completion)
|
||||
|
||||
private AudioManager audio;
|
||||
private boolean hasFocus;
|
||||
private boolean pausedByFocus;
|
||||
private boolean pausedByCall;
|
||||
private WifiManager.WifiLock wifiLock;
|
||||
private boolean noisyRegistered;
|
||||
private boolean foreground;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
instance = this;
|
||||
audio = (AudioManager) getSystemService(AUDIO_SERVICE);
|
||||
audio.registerMediaButtonEventReceiver(new ComponentName(this, MediaButtonReceiver.class));
|
||||
WifiManager wm = (WifiManager) getSystemService(WIFI_SERVICE);
|
||||
wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL, "minamp");
|
||||
wifiLock.setReferenceCounted(false);
|
||||
proxy = new StreamProxy();
|
||||
try { proxy.start(); } catch (IOException e) { /* reported when playback starts */ }
|
||||
try {
|
||||
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
|
||||
tm.listen(phoneListener, PhoneStateListener.LISTEN_CALL_STATE);
|
||||
} catch (Exception ignored) {}
|
||||
App.get().handler.post(ticker);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
App.get().handler.removeCallbacks(ticker);
|
||||
try {
|
||||
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
|
||||
tm.listen(phoneListener, PhoneStateListener.LISTEN_NONE);
|
||||
} catch (Exception ignored) {}
|
||||
if (track != null) PlayQueue.get().savePosition(currentPosition());
|
||||
releasePlayer();
|
||||
proxy.shutdown();
|
||||
abandonFocus();
|
||||
unregisterNoisy();
|
||||
if (wifiLock.isHeld()) wifiLock.release();
|
||||
audio.unregisterMediaButtonEventReceiver(new ComponentName(this, MediaButtonReceiver.class));
|
||||
worker.shutdownNow();
|
||||
reporter.shutdown();
|
||||
instance = null;
|
||||
state = IDLE;
|
||||
notifyListeners();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) { return null; }
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null) handle(intent);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
private void handle(Intent i) {
|
||||
String a = i.getAction();
|
||||
if (a == null) return;
|
||||
PlayQueue q = PlayQueue.get();
|
||||
if (ACTION_PLAY_CURRENT.equals(a)) {
|
||||
newTrack();
|
||||
startCurrent(i.getLongExtra(EXTRA_POS, 0), true);
|
||||
} else if (ACTION_TOGGLE.equals(a)) {
|
||||
if (state == PLAYING || state == BUFFERING) pause(); else play();
|
||||
} else if (ACTION_PLAY.equals(a)) {
|
||||
play();
|
||||
} else if (ACTION_PAUSE.equals(a)) {
|
||||
pause();
|
||||
} else if (ACTION_NEXT.equals(a)) {
|
||||
if (q.advance(true)) { newTrack(); startCurrent(0, true); }
|
||||
} else if (ACTION_PREV.equals(a)) {
|
||||
if (currentPosition() > 4000 && track != null) { seek(0); }
|
||||
else if (q.back()) { newTrack(); startCurrent(0, true); }
|
||||
} else if (ACTION_SEEK.equals(a)) {
|
||||
seek(i.getLongExtra(EXTRA_POS, 0));
|
||||
} else if (ACTION_STOP.equals(a)) {
|
||||
stopAll(null);
|
||||
} else if (ACTION_SLEEP.equals(a)) {
|
||||
int m = i.getIntExtra(EXTRA_MINUTES, 0);
|
||||
sleepEndOfTrack = m < 0;
|
||||
sleepAt = m > 0 ? System.currentTimeMillis() + m * 60000L : 0;
|
||||
notifyListeners();
|
||||
}
|
||||
if (state == IDLE && track == null) stopSelf();
|
||||
}
|
||||
|
||||
private void newTrack() {
|
||||
retries = 0;
|
||||
forceDownload = false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ playback pipeline
|
||||
|
||||
private void startCurrent(final long posMs, boolean play) {
|
||||
final Item it = PlayQueue.get().current();
|
||||
final App app = App.get();
|
||||
if (it == null || app.library == null) { stopAll(null); return; }
|
||||
final int g = ++gen;
|
||||
releasePlayer();
|
||||
track = it;
|
||||
scrobbled = false;
|
||||
pendingPos = posMs;
|
||||
offsetMs = 0;
|
||||
lastPos = posMs;
|
||||
playWhenReady = play;
|
||||
setState(BUFFERING, "Connecting…");
|
||||
goForeground();
|
||||
if (play) requestFocus();
|
||||
final boolean download = forceDownload || app.downloadFirst();
|
||||
worker.execute(new Runnable() {
|
||||
public void run() {
|
||||
try {
|
||||
Library lib = app.library;
|
||||
lib.ensureMedia(it);
|
||||
lib.server.base(); // choose LAN / remote / relay before deciding quality
|
||||
final int max = app.currentMaxKbps();
|
||||
final boolean direct = Library.canDirectPlay(it, max);
|
||||
final String sess = direct ? null : "minamp-" + Long.toString(System.currentTimeMillis(), 36);
|
||||
// Streamed transcodes start at the offset; downloads always fetch the whole track and seek in the file.
|
||||
final long startAt = (direct || download) ? 0 : (posMs / 1000) * 1000;
|
||||
final String url = direct ? lib.directUrl(it) : lib.transcodeUrl(it, max, startAt, sess);
|
||||
final String ext = direct ? extFor(it) : "mp3";
|
||||
File file = null;
|
||||
if (download) file = downloadTo(url, g, it);
|
||||
final File ff = file;
|
||||
post(new Runnable() {
|
||||
public void run() {
|
||||
if (g != gen) return;
|
||||
transcoding = !direct;
|
||||
session = sess;
|
||||
kbps = direct ? 0 : (max <= 0 ? 320 : max);
|
||||
begin(url, ext, ff, startAt, (direct || download) ? posMs : 0);
|
||||
}
|
||||
});
|
||||
} catch (final Exception e) {
|
||||
post(new Runnable() {
|
||||
public void run() { if (g == gen) trackFailed(message(e)); }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String extFor(Item it) {
|
||||
String c = it.container.toLowerCase();
|
||||
if ("mp4".equals(c) || "m4a".equals(c) || "m4b".equals(c)) return "m4a";
|
||||
if ("ogg".equals(c)) return "ogg";
|
||||
return "mp3";
|
||||
}
|
||||
|
||||
private File downloadTo(String url, int g, Item it) throws IOException {
|
||||
File base = null;
|
||||
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = getExternalCacheDir();
|
||||
if (base == null) base = getCacheDir();
|
||||
File f = new File(base, "play-" + (g % 2) + ".tmp");
|
||||
Http.Response r = Http.get(url, Plex.headers(null), 30000);
|
||||
if (r.code != 200) { r.close(); throw new IOException("Server said " + r.code); }
|
||||
InputStream in = r.body;
|
||||
FileOutputStream out = new FileOutputStream(f);
|
||||
try {
|
||||
byte[] buf = new byte[16384];
|
||||
long total = r.contentLength > 0 ? r.contentLength : (it.duration > 0 ? it.duration / 1000 * 24000 : 0);
|
||||
long got = 0, lastUi = 0;
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
if (g != gen) { Http.abort(r); throw new IOException("cancelled"); }
|
||||
out.write(buf, 0, n);
|
||||
got += n;
|
||||
if (total > 0 && got - lastUi > 65536) {
|
||||
lastUi = got;
|
||||
final int pct = (int) Math.min(99, got * 100 / total);
|
||||
final int fg = g;
|
||||
post(new Runnable() { public void run() { if (fg == gen) setState(BUFFERING, "Downloading " + pct + "%"); } });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
out.close();
|
||||
r.close();
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
private void begin(String url, String ext, File file, long offset, long seekTo) {
|
||||
try {
|
||||
mp = new MediaPlayer();
|
||||
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
|
||||
mp.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK);
|
||||
mp.setOnPreparedListener(this);
|
||||
mp.setOnCompletionListener(this);
|
||||
mp.setOnErrorListener(this);
|
||||
prepared = false;
|
||||
fromFile = file != null;
|
||||
offsetMs = offset;
|
||||
pendingPos = seekTo;
|
||||
if (fromFile) {
|
||||
FileInputStream fis = new FileInputStream(file);
|
||||
mp.setDataSource(fis.getFD());
|
||||
fis.close();
|
||||
} else {
|
||||
if (proxy.port() < 0) proxy.start();
|
||||
routeId = proxy.newId();
|
||||
mp.setDataSource(proxy.register(routeId, url, ext));
|
||||
}
|
||||
setState(BUFFERING, "Buffering…");
|
||||
mp.prepareAsync();
|
||||
} catch (Exception e) {
|
||||
trackFailed(message(e));
|
||||
}
|
||||
}
|
||||
|
||||
public void onPrepared(MediaPlayer p) {
|
||||
if (p != mp) return;
|
||||
prepared = true;
|
||||
failuresInRow = 0;
|
||||
if (pendingPos > 0) {
|
||||
try { mp.seekTo((int) pendingPos); } catch (Exception ignored) {}
|
||||
}
|
||||
pendingPos = 0;
|
||||
if (playWhenReady && (hasFocus || requestFocus())) {
|
||||
mp.start();
|
||||
setState(PLAYING, "");
|
||||
if (!wifiLock.isHeld()) wifiLock.acquire();
|
||||
registerNoisy();
|
||||
} else {
|
||||
setState(PAUSED, "");
|
||||
}
|
||||
goForeground();
|
||||
report(true);
|
||||
}
|
||||
|
||||
public void onCompletion(MediaPlayer p) {
|
||||
if (p != mp) return;
|
||||
long pos = lastPos;
|
||||
Item it = track;
|
||||
// A stream that ends well before the track does means the network dropped.
|
||||
if (it != null && it.duration > 0 && pos > 0 && pos < it.duration - 15000 && !fromFile) {
|
||||
trackFailed("Stream interrupted");
|
||||
return;
|
||||
}
|
||||
if (it != null) {
|
||||
if (!scrobbled) scrobble(it);
|
||||
reportState(it, "stopped", it.duration);
|
||||
}
|
||||
if (sleepEndOfTrack) {
|
||||
sleepEndOfTrack = false;
|
||||
PlayQueue.get().advance(false);
|
||||
stopAll("Sleep timer");
|
||||
return;
|
||||
}
|
||||
if (PlayQueue.get().advance(false)) {
|
||||
newTrack();
|
||||
startCurrent(0, true);
|
||||
} else {
|
||||
PlayQueue.get().savePosition(0);
|
||||
stopAll(null);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean onError(MediaPlayer p, int what, int extra) {
|
||||
if (p != mp) return true;
|
||||
trackFailed("Playback error (" + what + "/" + extra + ")");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void trackFailed(String msg) {
|
||||
long pos = Math.max(currentPosition(), lastPos);
|
||||
retries++;
|
||||
App app = App.get();
|
||||
if (retries == 1) {
|
||||
// Most failures on a phone are network changes: re-pick the connection and resume.
|
||||
if (app.server != null) app.server.invalidate();
|
||||
startCurrent(pos, true);
|
||||
} else if (retries == 2 && !forceDownload && !app.downloadFirst()) {
|
||||
forceDownload = true; // some old media stacks choke on streams but play files fine
|
||||
startCurrent(pos, true);
|
||||
} else {
|
||||
failuresInRow++;
|
||||
retries = 0;
|
||||
forceDownload = false;
|
||||
toast(msg);
|
||||
if (failuresInRow >= 3 || !PlayQueue.get().advance(true)) {
|
||||
failuresInRow = 0;
|
||||
stopAll(msg);
|
||||
} else {
|
||||
startCurrent(0, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ controls
|
||||
|
||||
private void play() {
|
||||
if (mp != null && prepared) {
|
||||
playWhenReady = true;
|
||||
if (!hasFocus && !requestFocus()) return;
|
||||
mp.start();
|
||||
setState(PLAYING, "");
|
||||
if (!wifiLock.isHeld()) wifiLock.acquire();
|
||||
registerNoisy();
|
||||
goForeground();
|
||||
report(true);
|
||||
} else if (state == BUFFERING) {
|
||||
playWhenReady = true;
|
||||
} else if (PlayQueue.get().current() != null) {
|
||||
newTrack();
|
||||
startCurrent(PlayQueue.get().resumePositionMs, true);
|
||||
}
|
||||
}
|
||||
|
||||
private void pause() {
|
||||
pausedByFocus = false;
|
||||
playWhenReady = false;
|
||||
if (mp != null && prepared) {
|
||||
try { mp.pause(); } catch (Exception ignored) {}
|
||||
setState(PAUSED, "");
|
||||
PlayQueue.get().savePosition(currentPosition());
|
||||
report(true);
|
||||
} else if (state == BUFFERING) {
|
||||
setState(BUFFERING, status);
|
||||
}
|
||||
if (wifiLock.isHeld()) wifiLock.release();
|
||||
unregisterNoisy();
|
||||
goForeground();
|
||||
}
|
||||
|
||||
private void seek(long ms) {
|
||||
if (track == null) return;
|
||||
if (ms < 0) ms = 0;
|
||||
if (track.duration > 0 && ms > track.duration - 1000) ms = Math.max(0, track.duration - 1000);
|
||||
if (mp != null && prepared && (!transcoding || fromFile)) {
|
||||
try { mp.seekTo((int) ms); } catch (Exception ignored) {}
|
||||
lastPos = ms;
|
||||
notifyListeners();
|
||||
} else {
|
||||
// transcoded streams can't seek: restart the transcode at the new offset
|
||||
startCurrent(ms, playWhenReady || state == PLAYING);
|
||||
}
|
||||
}
|
||||
|
||||
private void stopAll(String message) {
|
||||
if (track != null) {
|
||||
reportState(track, "stopped", currentPosition());
|
||||
PlayQueue.get().savePosition(state == IDLE ? 0 : currentPosition());
|
||||
}
|
||||
gen++;
|
||||
releasePlayer();
|
||||
track = null;
|
||||
sleepAt = 0;
|
||||
setState(IDLE, message == null ? "" : message);
|
||||
abandonFocus();
|
||||
unregisterNoisy();
|
||||
if (wifiLock.isHeld()) wifiLock.release();
|
||||
stopForeground(true);
|
||||
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).cancel(1);
|
||||
foreground = false;
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
private void releasePlayer() {
|
||||
if (routeId != null) { proxy.unregister(routeId); routeId = null; }
|
||||
if (session != null) {
|
||||
final String s = session;
|
||||
final App app = App.get();
|
||||
reporter.execute(new Runnable() { public void run() { if (app.library != null) app.library.stopTranscode(s); } });
|
||||
session = null;
|
||||
}
|
||||
if (mp != null) {
|
||||
try { mp.reset(); } catch (Exception ignored) {}
|
||||
try { mp.release(); } catch (Exception ignored) {}
|
||||
mp = null;
|
||||
}
|
||||
prepared = false;
|
||||
}
|
||||
|
||||
private long currentPosition() {
|
||||
if (mp != null && prepared) {
|
||||
try { return offsetMs + mp.getCurrentPosition(); } catch (Exception e) { return offsetMs; }
|
||||
}
|
||||
return pendingPos + offsetMs;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ticker / reporting
|
||||
|
||||
private final Runnable ticker = new Runnable() {
|
||||
public void run() {
|
||||
App.get().handler.postDelayed(this, 1000);
|
||||
long now = System.currentTimeMillis();
|
||||
if (sleepAt > 0 && now >= sleepAt) {
|
||||
sleepAt = 0;
|
||||
pause();
|
||||
toast("Sleep timer: paused");
|
||||
}
|
||||
if (state == PLAYING && track != null) {
|
||||
long pos = currentPosition();
|
||||
lastPos = pos;
|
||||
if (!scrobbled && track.duration > 0 && pos > track.duration * 9 / 10) scrobble(track);
|
||||
if (now - lastTimeline > 10000) report(false);
|
||||
if (now - lastSave > 15000) { lastSave = now; PlayQueue.get().resumePositionMs = pos; }
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private void report(boolean force) {
|
||||
if (track == null) return;
|
||||
lastTimeline = System.currentTimeMillis();
|
||||
String s = state == PLAYING ? "playing" : state == PAUSED ? "paused" : "buffering";
|
||||
reportState(track, s, currentPosition());
|
||||
}
|
||||
|
||||
private void reportState(final Item it, final String s, final long pos) {
|
||||
final App app = App.get();
|
||||
reporter.execute(new Runnable() {
|
||||
public void run() { if (app.library != null) app.library.timeline(it, s, pos); }
|
||||
});
|
||||
}
|
||||
|
||||
private void scrobble(final Item it) {
|
||||
scrobbled = true;
|
||||
final App app = App.get();
|
||||
reporter.execute(new Runnable() {
|
||||
public void run() { if (app.library != null) app.library.scrobble(it); }
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ focus, calls, noisy
|
||||
|
||||
private boolean requestFocus() {
|
||||
if (hasFocus) return true;
|
||||
hasFocus = audio.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
||||
== AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
|
||||
return hasFocus;
|
||||
}
|
||||
|
||||
private void abandonFocus() {
|
||||
if (hasFocus) audio.abandonAudioFocus(this);
|
||||
hasFocus = false;
|
||||
}
|
||||
|
||||
public void onAudioFocusChange(int change) {
|
||||
switch (change) {
|
||||
case AudioManager.AUDIOFOCUS_GAIN:
|
||||
hasFocus = true;
|
||||
if (mp != null) try { mp.setVolume(1f, 1f); } catch (Exception ignored) {}
|
||||
if (pausedByFocus) { pausedByFocus = false; play(); }
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_LOSS:
|
||||
hasFocus = false;
|
||||
pause();
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
||||
if (state == PLAYING) { pause(); pausedByFocus = true; }
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
||||
if (mp != null) try { mp.setVolume(0.2f, 0.2f); } catch (Exception ignored) {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private final PhoneStateListener phoneListener = new PhoneStateListener() {
|
||||
@Override
|
||||
public void onCallStateChanged(int s, String number) {
|
||||
if (s == TelephonyManager.CALL_STATE_RINGING || s == TelephonyManager.CALL_STATE_OFFHOOK) {
|
||||
if (state == PLAYING || state == BUFFERING) { pause(); pausedByCall = true; }
|
||||
} else if (s == TelephonyManager.CALL_STATE_IDLE && pausedByCall) {
|
||||
pausedByCall = false;
|
||||
play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final BroadcastReceiver noisy = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context c, Intent i) { pause(); }
|
||||
};
|
||||
|
||||
private void registerNoisy() {
|
||||
if (noisyRegistered) return;
|
||||
registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
|
||||
noisyRegistered = true;
|
||||
}
|
||||
|
||||
private void unregisterNoisy() {
|
||||
if (!noisyRegistered) return;
|
||||
try { unregisterReceiver(noisy); } catch (Exception ignored) {}
|
||||
noisyRegistered = false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ notification / state
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private void goForeground() {
|
||||
Item it = track != null ? track : PlayQueue.get().current();
|
||||
if (it == null) return;
|
||||
Notification n = new Notification(R.drawable.ic_stat, null, 0);
|
||||
n.flags |= Notification.FLAG_ONGOING_EVENT | Notification.FLAG_NO_CLEAR;
|
||||
Intent open = new Intent(this, NowPlayingActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
PendingIntent pi = PendingIntent.getActivity(this, 0, open, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
String line = (state == PAUSED ? "Paused · " : "") + it.artist;
|
||||
n.setLatestEventInfo(this, it.title, line, pi);
|
||||
if (state == PAUSED || state == IDLE) {
|
||||
// let Android reclaim us while paused, but keep the notification as a shortcut back
|
||||
if (foreground) { stopForeground(false); foreground = false; }
|
||||
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).notify(1, n);
|
||||
} else {
|
||||
startForeground(1, n);
|
||||
foreground = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void setState(int s, String msg) {
|
||||
state = s;
|
||||
status = msg == null ? "" : msg;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
private static void notifyListeners() {
|
||||
for (int i = listeners.size() - 1; i >= 0; i--) {
|
||||
if (i < listeners.size()) listeners.get(i).onPlayerChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void post(Runnable r) { App.get().handler.post(r); }
|
||||
|
||||
private void toast(String m) {
|
||||
if (m != null && m.length() > 0) Toast.makeText(this, m, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
static String message(Exception e) {
|
||||
String m = e.getMessage();
|
||||
if (m == null || m.length() == 0) m = e.getClass().getSimpleName();
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.minamp;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ListView;
|
||||
|
||||
import com.minamp.plex.Item;
|
||||
|
||||
public class QueueActivity extends BaseActivity {
|
||||
|
||||
private ItemAdapter adapter;
|
||||
private ListView list;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
setContentView(R.layout.activity_queue);
|
||||
setupTopBar("Up next", "", true);
|
||||
barAction(R.id.btn_action, Glyph.SHUFFLE, new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
PlayQueue.get().setShuffle(!PlayQueue.get().shuffle());
|
||||
toast(PlayQueue.get().shuffle() ? "Shuffle on" : "Shuffle off");
|
||||
}
|
||||
});
|
||||
barAction(R.id.btn_action2, Glyph.MORE, new View.OnClickListener() {
|
||||
public void onClick(View v) { clearDialog(); }
|
||||
});
|
||||
list = (ListView) findViewById(R.id.list);
|
||||
adapter = new ItemAdapter(this, ItemAdapter.MODE_QUEUE);
|
||||
list.setAdapter(adapter);
|
||||
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
|
||||
public void onItemClick(AdapterView<?> p, View v, int pos, long id) {
|
||||
PlayQueue.get().jump(pos);
|
||||
PlayerService.playCurrent(QueueActivity.this, 0);
|
||||
}
|
||||
});
|
||||
list.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
|
||||
public boolean onItemLongClick(AdapterView<?> p, View v, final int pos, long id) {
|
||||
final Item it = PlayQueue.get().at(pos);
|
||||
final boolean isCurrent = pos == PlayQueue.get().index();
|
||||
String[] opts = isCurrent ? new String[] {"Go to album", "Go to artist"}
|
||||
: new String[] {"Play next", "Remove from queue", "Go to album", "Go to artist"};
|
||||
new AlertDialog.Builder(QueueActivity.this).setTitle(it.title)
|
||||
.setItems(opts, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
int action = isCurrent ? w + 2 : w;
|
||||
switch (action) {
|
||||
case 0: PlayQueue.get().moveNext(pos); break;
|
||||
case 1: PlayQueue.get().remove(pos); break;
|
||||
case 2: if (it.albumKey.length() > 0) openKey(it.albumKey, Item.ALBUM, it.album); break;
|
||||
case 3: if (it.artistKey.length() > 0) openKey(it.artistKey, Item.ARTIST, it.artist); break;
|
||||
}
|
||||
}
|
||||
}).show();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
refresh(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onQueueChanged() { super.onQueueChanged(); refresh(false); }
|
||||
|
||||
private void refresh(boolean scroll) {
|
||||
PlayQueue q = PlayQueue.get();
|
||||
adapter.set(q.items(), -1);
|
||||
adapter.setHighlight(q.index());
|
||||
setBarSub(q.size() + (q.size() == 1 ? " track" : " tracks") + (q.title().length() > 0 ? " · " + q.title() : ""));
|
||||
if (q.size() == 0) showStatus(false, "The queue is empty", null); else hideStatus();
|
||||
if (scroll && q.index() > 0) list.setSelection(Math.max(0, q.index() - 1));
|
||||
}
|
||||
|
||||
private void clearDialog() {
|
||||
new AlertDialog.Builder(this)
|
||||
.setItems(new String[] {"Clear upcoming tracks", "Clear everything"}, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
PlayQueue q = PlayQueue.get();
|
||||
if (w == 0) {
|
||||
q.clearUpcoming();
|
||||
} else {
|
||||
PlayerService.send(QueueActivity.this, PlayerService.ACTION_STOP);
|
||||
q.clear();
|
||||
}
|
||||
}
|
||||
}).show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.minamp.plex.Item;
|
||||
import com.minamp.plex.Library;
|
||||
|
||||
public class SearchActivity extends BaseActivity {
|
||||
|
||||
private EditText box;
|
||||
private ItemAdapter adapter;
|
||||
private ListView list;
|
||||
private int seq;
|
||||
private String lastQuery = "";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
if (app.library == null) { finish(); return; }
|
||||
setContentView(R.layout.activity_search);
|
||||
ImageButton back = (ImageButton) findViewById(R.id.btn_back);
|
||||
Ui.icon(back, Glyph.BACK, getResources().getColor(R.color.text));
|
||||
back.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { finish(); } });
|
||||
|
||||
box = (EditText) findViewById(R.id.search_box);
|
||||
list = (ListView) findViewById(R.id.list);
|
||||
adapter = new ItemAdapter(this, ItemAdapter.MODE_DEFAULT);
|
||||
list.setAdapter(adapter);
|
||||
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
|
||||
public void onItemClick(AdapterView<?> p, View v, int pos, long id) {
|
||||
Item it = adapter.getItem(pos);
|
||||
hideKeyboard();
|
||||
open(it, null, 0, "Search");
|
||||
}
|
||||
});
|
||||
list.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
|
||||
public boolean onItemLongClick(AdapterView<?> p, View v, int pos, long id) {
|
||||
itemMenu(adapter.getItem(pos));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
list.setOnTouchListener(new View.OnTouchListener() {
|
||||
public boolean onTouch(View v, android.view.MotionEvent e) { hideKeyboard(); return false; }
|
||||
});
|
||||
|
||||
box.addTextChangedListener(new TextWatcher() {
|
||||
public void beforeTextChanged(CharSequence s, int a, int b, int c) {}
|
||||
public void onTextChanged(CharSequence s, int a, int b, int c) {}
|
||||
public void afterTextChanged(Editable s) {
|
||||
app.handler.removeCallbacks(debounce);
|
||||
app.handler.postDelayed(debounce, 600); // slow phone: don't search on every key
|
||||
}
|
||||
});
|
||||
box.setOnEditorActionListener(new TextView.OnEditorActionListener() {
|
||||
public boolean onEditorAction(TextView v, int action, KeyEvent e) {
|
||||
if (action == EditorInfo.IME_ACTION_SEARCH || (e != null && e.getKeyCode() == KeyEvent.KEYCODE_ENTER)) {
|
||||
app.handler.removeCallbacks(debounce);
|
||||
search(true);
|
||||
hideKeyboard();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
showStatus(false, "Search " + app.sectionTitle(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
app.handler.removeCallbacks(debounce);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private final Runnable debounce = new Runnable() { public void run() { search(false); } };
|
||||
|
||||
private void hideKeyboard() {
|
||||
InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
|
||||
imm.hideSoftInputFromWindow(box.getWindowToken(), 0);
|
||||
}
|
||||
|
||||
private void search(boolean force) {
|
||||
final String q = box.getText().toString().trim();
|
||||
if (q.length() < 2) { adapter.set(new java.util.ArrayList<Item>(), -1); showStatus(false, "Search " + app.sectionTitle(), null); return; }
|
||||
if (!force && q.equals(lastQuery)) return;
|
||||
lastQuery = q;
|
||||
final int my = ++seq;
|
||||
final Library lib = lib();
|
||||
showStatus(true, "Searching…", null);
|
||||
run(new Task<List<Item>>() {
|
||||
public List<Item> work() throws Exception { return lib.search(q); }
|
||||
public void done(List<Item> r) {
|
||||
if (my != seq) return;
|
||||
adapter.set(r, -1);
|
||||
list.setSelection(0);
|
||||
if (r.isEmpty()) showStatus(false, "Nothing found for “" + q + "”", null); else hideStatus();
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
if (my != seq) return;
|
||||
showStatus(false, msg(e), new Runnable() { public void run() { search(true); } });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package com.minamp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.minamp.plex.Plex;
|
||||
import com.minamp.plex.Server;
|
||||
import com.minamp.util.Json;
|
||||
|
||||
/** Sign in with a plex.tv/link code (or a manual address), then pick a server and a music library. */
|
||||
public class SetupActivity extends BaseActivity {
|
||||
|
||||
public static final String EXTRA_MODE = "mode";
|
||||
public static final int MODE_WELCOME = 0, MODE_SERVER = 1, MODE_LIBRARY = 2;
|
||||
|
||||
private View welcome, pinPage, manual, choose, busy;
|
||||
private TextView pinCode, pinStatus, busyText, chooseTitle, chooseSub;
|
||||
private LinearLayout chooseList;
|
||||
private Runnable chooseBack;
|
||||
private long pinId;
|
||||
private long pinStarted;
|
||||
private int pinSeq;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
setContentView(R.layout.activity_setup);
|
||||
welcome = findViewById(R.id.page_welcome);
|
||||
pinPage = findViewById(R.id.page_pin);
|
||||
manual = findViewById(R.id.page_manual);
|
||||
choose = findViewById(R.id.page_choose);
|
||||
busy = findViewById(R.id.page_busy);
|
||||
pinCode = (TextView) findViewById(R.id.pin_code);
|
||||
pinStatus = (TextView) findViewById(R.id.pin_status);
|
||||
busyText = (TextView) findViewById(R.id.busy_text);
|
||||
chooseTitle = (TextView) findViewById(R.id.choose_title);
|
||||
chooseSub = (TextView) findViewById(R.id.choose_sub);
|
||||
chooseList = (LinearLayout) findViewById(R.id.choose_list);
|
||||
|
||||
findViewById(R.id.btn_signin).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { startPin(); } });
|
||||
findViewById(R.id.btn_manual).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { show(manual); } });
|
||||
findViewById(R.id.btn_pin_cancel).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { pinSeq++; show(welcome); } });
|
||||
findViewById(R.id.btn_manual_back).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { show(welcome); } });
|
||||
findViewById(R.id.btn_manual_go).setOnClickListener(new View.OnClickListener() { public void onClick(View v) { connectManual(); } });
|
||||
findViewById(R.id.btn_choose_back).setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { if (chooseBack != null) chooseBack.run(); else show(welcome); }
|
||||
});
|
||||
|
||||
int mode = getIntent().getIntExtra(EXTRA_MODE, MODE_WELCOME);
|
||||
String account = app.prefs.getString("account", null);
|
||||
if (mode == MODE_LIBRARY && app.server != null) {
|
||||
loadSections(app.server, null);
|
||||
} else if (mode == MODE_SERVER && account != null) {
|
||||
loadServers(account);
|
||||
} else {
|
||||
show(welcome);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
pinSeq++;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(android.view.Menu menu) { return false; }
|
||||
|
||||
private void show(View page) {
|
||||
View[] all = {welcome, pinPage, manual, choose, busy};
|
||||
for (int i = 0; i < all.length; i++) all[i].setVisibility(all[i] == page ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private void showBusy(String text) {
|
||||
busyText.setText(text);
|
||||
show(busy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && welcome.getVisibility() != View.VISIBLE && app.isConfigured() == false) {
|
||||
pinSeq++;
|
||||
show(welcome);
|
||||
return true;
|
||||
}
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ PIN sign-in
|
||||
|
||||
private void startPin() {
|
||||
showBusy("Contacting plex.tv…\n(the first secure connection takes a few seconds on this phone)");
|
||||
final int my = ++pinSeq;
|
||||
run(new Task<Plex.Pin>() {
|
||||
public Plex.Pin work() throws Exception { return Plex.createPin(); }
|
||||
public void done(Plex.Pin p) {
|
||||
if (my != pinSeq) return;
|
||||
pinId = p.id;
|
||||
pinStarted = System.currentTimeMillis();
|
||||
StringBuilder spaced = new StringBuilder();
|
||||
for (int i = 0; i < p.code.length(); i++) { if (i > 0) spaced.append(' '); spaced.append(p.code.charAt(i)); }
|
||||
pinCode.setText(spaced.toString());
|
||||
pinStatus.setText("Waiting for you to link…");
|
||||
show(pinPage);
|
||||
pollPin(my);
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
if (my != pinSeq) return;
|
||||
show(welcome);
|
||||
toast("Couldn't reach plex.tv: " + msg(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void pollPin(final int my) {
|
||||
app.handler.postDelayed(new Runnable() {
|
||||
public void run() {
|
||||
if (my != pinSeq || destroyed) return;
|
||||
if (System.currentTimeMillis() - pinStarted > 14 * 60 * 1000) {
|
||||
pinStatus.setText("Code expired — go back and try again");
|
||||
return;
|
||||
}
|
||||
SetupActivity.this.run(new Task<String>() {
|
||||
public String work() throws Exception { return Plex.checkPin(pinId); }
|
||||
public void done(String token) {
|
||||
if (my != pinSeq) return;
|
||||
if (token == null) { pollPin(my); return; }
|
||||
pinSeq++;
|
||||
signedIn(token);
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
if (my != pinSeq) return;
|
||||
pinStatus.setText("Retrying… (" + msg(e) + ")");
|
||||
pollPin(my);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
private void signedIn(final String account) {
|
||||
showBusy("Signed in. Finding your servers…");
|
||||
app.prefs.edit().putString("account", account).commit();
|
||||
run(new Task<String>() {
|
||||
public String work() throws Exception { return Plex.username(account); }
|
||||
public void done(String u) { app.prefs.edit().putString("username", u).commit(); }
|
||||
});
|
||||
loadServers(account);
|
||||
}
|
||||
|
||||
private void loadServers(final String account) {
|
||||
showBusy("Finding your servers…");
|
||||
run(new Task<List<Server>>() {
|
||||
public List<Server> work() throws Exception { return Plex.servers(account); }
|
||||
public void done(final List<Server> servers) {
|
||||
if (servers.isEmpty()) {
|
||||
show(welcome);
|
||||
toast("No Plex Media Servers on this account");
|
||||
return;
|
||||
}
|
||||
if (servers.size() == 1) { pickServer(servers.get(0), null); return; }
|
||||
chooseTitle.setText("Choose a server");
|
||||
chooseSub.setText("Signed in as " + app.prefs.getString("username", "you"));
|
||||
chooseList.removeAllViews();
|
||||
for (int i = 0; i < servers.size(); i++) {
|
||||
final Server s = servers.get(i);
|
||||
int remote = 0, local = 0;
|
||||
for (int k = 0; k < s.connections.size(); k++) {
|
||||
int kind = s.connections.get(k).kind;
|
||||
if (kind == Server.LOCAL || kind == Server.LOCAL_HTTP) local++; else remote++;
|
||||
}
|
||||
String sub = (s.owned ? "Yours" : "Shared with you") + " · " + (local > 0 ? "home + " : "") + (remote > 0 ? "remote" : "local only");
|
||||
addChoice(s.name, sub, new View.OnClickListener() {
|
||||
public void onClick(View v) { pickServer(s, new Runnable() { public void run() { done(servers); } }); }
|
||||
});
|
||||
}
|
||||
chooseBack = new Runnable() { public void run() { show(welcome); } };
|
||||
show(choose);
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
show(welcome);
|
||||
toast("Couldn't list servers: " + msg(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void addChoice(String title, String sub, View.OnClickListener l) {
|
||||
View row = getLayoutInflater().inflate(R.layout.row_choice, chooseList, false);
|
||||
((TextView) row.findViewById(R.id.choice_title)).setText(title);
|
||||
((TextView) row.findViewById(R.id.choice_sub)).setText(sub);
|
||||
row.setOnClickListener(l);
|
||||
chooseList.addView(row);
|
||||
}
|
||||
|
||||
private void pickServer(final Server s, final Runnable back) {
|
||||
showBusy("Connecting to " + s.name + "…\nTrying home network, then remote, then relay.");
|
||||
run(new Task<Server.Conn>() {
|
||||
public Server.Conn work() throws Exception { return s.pick(); }
|
||||
public void done(Server.Conn c) {
|
||||
loadSections(s, back);
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
if (back != null) back.run(); else show(welcome);
|
||||
toast(msg(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ manual
|
||||
|
||||
private void connectManual() {
|
||||
String url = ((EditText) findViewById(R.id.edit_url)).getText().toString().trim();
|
||||
String token = ((EditText) findViewById(R.id.edit_token)).getText().toString().trim();
|
||||
if (url.length() == 0) { toast("Enter your server's address"); return; }
|
||||
final Server s = new Server();
|
||||
s.name = "Plex server";
|
||||
s.token = token;
|
||||
s.add(Server.normalizeManual(url), Server.MANUAL);
|
||||
showBusy("Connecting…");
|
||||
run(new Task<String>() {
|
||||
public String work() throws Exception {
|
||||
s.pick();
|
||||
Json.Obj mc = s.get("/");
|
||||
return mc == null ? "" : mc.optString("friendlyName", "");
|
||||
}
|
||||
public void done(String name) {
|
||||
if (name.length() > 0) s.name = name;
|
||||
loadSections(s, new Runnable() { public void run() { show(manual); } });
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
show(manual);
|
||||
boolean auth = e instanceof Plex.PlexException && ((Plex.PlexException) e).code == 401;
|
||||
toast(auth ? "The server wants a token (or sign in with Plex instead)" : msg(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ libraries
|
||||
|
||||
private void loadSections(final Server s, final Runnable back) {
|
||||
showBusy("Loading libraries…");
|
||||
run(new Task<List<Server.Section>>() {
|
||||
public List<Server.Section> work() throws Exception { return s.musicSections(); }
|
||||
public void done(List<Server.Section> secs) {
|
||||
if (secs.isEmpty()) {
|
||||
if (back != null) back.run(); else show(welcome);
|
||||
toast(s.name + " has no music libraries");
|
||||
return;
|
||||
}
|
||||
if (secs.size() == 1) { finishWith(s, secs.get(0)); return; }
|
||||
chooseTitle.setText("Choose a music library");
|
||||
chooseSub.setText(s.name);
|
||||
chooseList.removeAllViews();
|
||||
for (int i = 0; i < secs.size(); i++) {
|
||||
final Server.Section sec = secs.get(i);
|
||||
addChoice(sec.title, "Music", new View.OnClickListener() {
|
||||
public void onClick(View v) { finishWith(s, sec); }
|
||||
});
|
||||
}
|
||||
chooseBack = back != null ? back : new Runnable() { public void run() { finish(); } };
|
||||
show(choose);
|
||||
}
|
||||
public void fail(Exception e) {
|
||||
if (back != null) back.run(); else show(welcome);
|
||||
toast(msg(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void finishWith(Server s, Server.Section sec) {
|
||||
boolean sameServer = app.server != null && app.server.machineId.equals(s.machineId) && s.machineId.length() > 0;
|
||||
if (!sameServer || app.server != s) {
|
||||
if (!sameServer) {
|
||||
PlayerService.send(this, PlayerService.ACTION_STOP);
|
||||
PlayQueue.get().clear();
|
||||
}
|
||||
app.saveServer(s);
|
||||
}
|
||||
app.saveSection(sec.key, sec.title);
|
||||
startActivity(new Intent(this, HomeActivity.class).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
|
||||
finish();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.minamp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.ImageView;
|
||||
|
||||
/** Square as large as fits in the space it's given (album art on Now Playing). */
|
||||
public class SquareImageView extends ImageView {
|
||||
public SquareImageView(Context c) { super(c); }
|
||||
public SquareImageView(Context c, AttributeSet a) { super(c, a); }
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int w, int h) {
|
||||
int ws = MeasureSpec.getSize(w), hs = MeasureSpec.getSize(h);
|
||||
int wm = MeasureSpec.getMode(w), hm = MeasureSpec.getMode(h);
|
||||
int size;
|
||||
if (wm == MeasureSpec.UNSPECIFIED) size = hs;
|
||||
else if (hm == MeasureSpec.UNSPECIFIED) size = ws;
|
||||
else size = Math.min(ws, hs);
|
||||
setMeasuredDimension(size, size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.minamp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Color;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
|
||||
/** Small UI helpers. */
|
||||
public final class Ui {
|
||||
private Ui() {}
|
||||
|
||||
public static int dp(Context c, float dp) {
|
||||
return (int) (dp * c.getResources().getDisplayMetrics().density + 0.5f);
|
||||
}
|
||||
|
||||
public static Glyph glyph(Context c, int type, int color, float sizeDp) {
|
||||
return new Glyph(type, color, dp(c, sizeDp));
|
||||
}
|
||||
|
||||
public static void icon(ImageView v, int type, int color, float sizeDp) {
|
||||
Context c = v.getContext();
|
||||
Glyph g = glyph(c, type, color, sizeDp);
|
||||
g.scale(1f);
|
||||
v.setImageDrawable(g);
|
||||
}
|
||||
|
||||
public static void icon(ImageButton v, int type, int color) {
|
||||
icon(v, type, color, 20);
|
||||
}
|
||||
|
||||
public static String time(long ms) {
|
||||
if (ms < 0) ms = 0;
|
||||
long s = ms / 1000;
|
||||
long h = s / 3600;
|
||||
long m = (s / 60) % 60;
|
||||
s = s % 60;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (h > 0) sb.append(h).append(':').append(m < 10 ? "0" : "");
|
||||
sb.append(m).append(':').append(s < 10 ? "0" : "").append(s);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String longDuration(long ms) {
|
||||
long min = (ms + 30000) / 60000;
|
||||
if (min < 60) return min + " min";
|
||||
return (min / 60) + " h " + (min % 60) + " min";
|
||||
}
|
||||
|
||||
/** Average colour of a bitmap, nudged towards something vivid but dark enough for white text. */
|
||||
public static int backdropColor(Bitmap b) {
|
||||
if (b == null) return 0xFF1D1D22;
|
||||
Bitmap small = Bitmap.createScaledBitmap(b, 12, 12, true);
|
||||
long r = 0, g = 0, bl = 0, wsum = 0;
|
||||
for (int y = 0; y < 12; y++) {
|
||||
for (int x = 0; x < 12; x++) {
|
||||
int p = small.getPixel(x, y);
|
||||
int pr = Color.red(p), pg = Color.green(p), pb = Color.blue(p);
|
||||
int max = Math.max(pr, Math.max(pg, pb)), min = Math.min(pr, Math.min(pg, pb));
|
||||
int w = 1 + (max - min); // favour saturated pixels
|
||||
r += pr * w; g += pg * w; bl += pb * w; wsum += w;
|
||||
}
|
||||
}
|
||||
if (small != b) small.recycle();
|
||||
float[] hsv = new float[3];
|
||||
Color.RGBToHSV((int) (r / wsum), (int) (g / wsum), (int) (bl / wsum), hsv);
|
||||
hsv[1] = Math.min(1f, hsv[1] * 1.2f);
|
||||
hsv[2] = Math.min(hsv[2], 0.42f);
|
||||
return Color.HSVToColor(hsv);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.minamp.net;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import com.minamp.bc.asn1.ASN1Encoding;
|
||||
import com.minamp.bc.asn1.ASN1ObjectIdentifier;
|
||||
import com.minamp.bc.asn1.ASN1OctetString;
|
||||
import com.minamp.bc.asn1.pkcs.PKCSObjectIdentifiers;
|
||||
import com.minamp.bc.asn1.x500.RDN;
|
||||
import com.minamp.bc.asn1.x500.X500Name;
|
||||
import com.minamp.bc.asn1.x500.style.BCStyle;
|
||||
import com.minamp.bc.asn1.x500.style.IETFUtils;
|
||||
import com.minamp.bc.asn1.x509.BasicConstraints;
|
||||
import com.minamp.bc.asn1.x509.Certificate;
|
||||
import com.minamp.bc.asn1.x509.Extension;
|
||||
import com.minamp.bc.asn1.x509.Extensions;
|
||||
import com.minamp.bc.asn1.x509.GeneralName;
|
||||
import com.minamp.bc.asn1.x509.GeneralNames;
|
||||
import com.minamp.bc.asn1.x9.X9ObjectIdentifiers;
|
||||
import com.minamp.bc.crypto.Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA1Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA256Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA384Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA512Digest;
|
||||
import com.minamp.bc.crypto.params.AsymmetricKeyParameter;
|
||||
import com.minamp.bc.crypto.signers.DSADigestSigner;
|
||||
import com.minamp.bc.crypto.signers.ECDSASigner;
|
||||
import com.minamp.bc.crypto.signers.RSADigestSigner;
|
||||
import com.minamp.bc.crypto.util.PublicKeyFactory;
|
||||
|
||||
/**
|
||||
* Minimal Web PKI path validation for the bundled TLS stack:
|
||||
* hostname match, validity window, signature chain up to a bundled Mozilla root.
|
||||
*/
|
||||
public final class CertVerifier {
|
||||
|
||||
/** Lookup of trusted root certificates (DER) by the DER encoding of their subject name. */
|
||||
public interface RootStore {
|
||||
List<byte[]> find(byte[] subjectDer);
|
||||
}
|
||||
|
||||
private static RootStore roots;
|
||||
|
||||
public static void setRootStore(RootStore store) { roots = store; }
|
||||
|
||||
private CertVerifier() {}
|
||||
|
||||
public static void verify(List<byte[]> chainDer, String host) throws IOException {
|
||||
if (chainDer == null || chainDer.isEmpty()) throw new IOException("Server sent no certificate");
|
||||
if (roots == null) throw new IOException("No trust store configured");
|
||||
List<Certificate> chain = new ArrayList<Certificate>();
|
||||
for (int i = 0; i < chainDer.size(); i++) chain.add(Certificate.getInstance(chainDer.get(i)));
|
||||
|
||||
Certificate leaf = chain.get(0);
|
||||
if (!hostMatches(leaf, host)) {
|
||||
throw new IOException("Certificate does not match " + host);
|
||||
}
|
||||
|
||||
Date now = new Date();
|
||||
// An unset clock on an old phone (e.g. 1980 after a battery pull) would make every
|
||||
// certificate look "not yet valid"; only enforce dates when the clock looks sane.
|
||||
boolean clockSane = now.getTime() > 1704067200000L; // 2024-01-01
|
||||
|
||||
Certificate current = leaf;
|
||||
for (int depth = 0; depth < 8; depth++) {
|
||||
if (clockSane) checkDates(current, now);
|
||||
|
||||
byte[] issuerDer = current.getIssuer().getEncoded(ASN1Encoding.DER);
|
||||
|
||||
// 1. Anchored directly by a trusted root?
|
||||
List<byte[]> anchors = roots.find(issuerDer);
|
||||
if (anchors != null) {
|
||||
for (int i = 0; i < anchors.size(); i++) {
|
||||
Certificate root = Certificate.getInstance(anchors.get(i));
|
||||
if (signatureValid(current, root)) return; // trusted
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Otherwise find the issuing intermediate among what the server sent.
|
||||
Certificate next = null;
|
||||
for (int i = 1; i < chain.size(); i++) {
|
||||
Certificate cand = chain.get(i);
|
||||
if (cand == current) continue;
|
||||
if (!java.util.Arrays.equals(cand.getSubject().getEncoded(ASN1Encoding.DER), issuerDer)) continue;
|
||||
if (!isCa(cand)) continue;
|
||||
if (signatureValid(current, cand)) { next = cand; break; }
|
||||
}
|
||||
if (next == null) throw new IOException("Untrusted certificate chain for " + host);
|
||||
current = next;
|
||||
}
|
||||
throw new IOException("Certificate chain too long");
|
||||
}
|
||||
|
||||
private static void checkDates(Certificate c, Date now) throws IOException {
|
||||
Date from = c.getStartDate().getDate();
|
||||
Date to = c.getEndDate().getDate();
|
||||
if (now.before(from) || now.after(to)) {
|
||||
throw new IOException("Certificate expired or not yet valid (check the phone's date)");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isCa(Certificate c) {
|
||||
Extensions ext = c.getTBSCertificate().getExtensions();
|
||||
if (ext == null) return c.getTBSCertificate().getVersionNumber() < 3; // v1 roots/intermediates
|
||||
BasicConstraints bc = BasicConstraints.fromExtensions(ext);
|
||||
return bc != null && bc.isCA();
|
||||
}
|
||||
|
||||
static boolean signatureValid(Certificate subject, Certificate issuer) {
|
||||
try {
|
||||
ASN1ObjectIdentifier alg = subject.getSignatureAlgorithm().getAlgorithm();
|
||||
byte[] tbs = subject.getTBSCertificate().getEncoded(ASN1Encoding.DER);
|
||||
byte[] sig = subject.getSignature().getOctets();
|
||||
AsymmetricKeyParameter key = PublicKeyFactory.createKey(issuer.getSubjectPublicKeyInfo());
|
||||
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha256WithRSAEncryption)) return rsa(new SHA256Digest(), key, tbs, sig);
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha384WithRSAEncryption)) return rsa(new SHA384Digest(), key, tbs, sig);
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha512WithRSAEncryption)) return rsa(new SHA512Digest(), key, tbs, sig);
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha1WithRSAEncryption)) return rsa(new SHA1Digest(), key, tbs, sig);
|
||||
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA256)) return ecdsa(new SHA256Digest(), key, tbs, sig);
|
||||
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA384)) return ecdsa(new SHA384Digest(), key, tbs, sig);
|
||||
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA512)) return ecdsa(new SHA512Digest(), key, tbs, sig);
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean rsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
|
||||
RSADigestSigner s = new RSADigestSigner(d);
|
||||
s.init(false, key);
|
||||
s.update(tbs, 0, tbs.length);
|
||||
return s.verifySignature(sig);
|
||||
}
|
||||
|
||||
private static boolean ecdsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
|
||||
DSADigestSigner s = new DSADigestSigner(new ECDSASigner(), d);
|
||||
s.init(false, key);
|
||||
s.update(tbs, 0, tbs.length);
|
||||
return s.verifySignature(sig);
|
||||
}
|
||||
|
||||
// ---- hostname checking ----
|
||||
|
||||
static boolean hostMatches(Certificate leaf, String host) {
|
||||
host = host.toLowerCase(Locale.US);
|
||||
boolean ip = isIpLiteral(host);
|
||||
boolean sawDns = false;
|
||||
Extensions ext = leaf.getTBSCertificate().getExtensions();
|
||||
if (ext != null) {
|
||||
GeneralNames names = GeneralNames.fromExtensions(ext, Extension.subjectAlternativeName);
|
||||
if (names != null) {
|
||||
GeneralName[] arr = names.getNames();
|
||||
for (int i = 0; i < arr.length; i++) {
|
||||
GeneralName n = arr[i];
|
||||
if (n.getTagNo() == GeneralName.dNSName) {
|
||||
sawDns = true;
|
||||
if (!ip && dnsMatch(n.getName().toString(), host)) return true;
|
||||
} else if (n.getTagNo() == GeneralName.iPAddress && ip) {
|
||||
byte[] a = ASN1OctetString.getInstance(n.getName()).getOctets();
|
||||
if (ipString(a).equals(host)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sawDns && !ip) {
|
||||
X500Name subject = X500Name.getInstance(leaf.getSubject());
|
||||
RDN[] cns = subject.getRDNs(BCStyle.CN);
|
||||
for (int i = 0; i < cns.length; i++) {
|
||||
String cn = IETFUtils.valueToString(cns[i].getFirst().getValue());
|
||||
if (dnsMatch(cn, host)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean dnsMatch(String pattern, String host) {
|
||||
pattern = pattern.toLowerCase(Locale.US);
|
||||
if (pattern.endsWith(".")) pattern = pattern.substring(0, pattern.length() - 1);
|
||||
if (host.endsWith(".")) host = host.substring(0, host.length() - 1);
|
||||
if (pattern.startsWith("*.")) {
|
||||
String suffix = pattern.substring(1); // ".example.com"
|
||||
if (!host.endsWith(suffix)) return false;
|
||||
String label = host.substring(0, host.length() - suffix.length());
|
||||
return label.length() > 0 && label.indexOf('.') < 0;
|
||||
}
|
||||
return pattern.equals(host);
|
||||
}
|
||||
|
||||
static boolean isIpLiteral(String host) {
|
||||
if (host.indexOf(':') >= 0) return true;
|
||||
for (int i = 0; i < host.length(); i++) {
|
||||
char c = host.charAt(i);
|
||||
if (!(c == '.' || (c >= '0' && c <= '9'))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String ipString(byte[] a) {
|
||||
if (a.length == 4) {
|
||||
return (a[0] & 255) + "." + (a[1] & 255) + "." + (a[2] & 255) + "." + (a[3] & 255);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public static String sha1Hex(byte[] data) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
byte[] h = md.digest(data);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < h.length; i++) {
|
||||
String s = Integer.toHexString(h[i] & 255);
|
||||
if (s.length() == 1) sb.append('0');
|
||||
sb.append(s);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package com.minamp.net;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Vector;
|
||||
|
||||
import com.minamp.bc.tls.DefaultTlsClient;
|
||||
import com.minamp.bc.tls.NameType;
|
||||
import com.minamp.bc.tls.ServerName;
|
||||
import com.minamp.bc.tls.TlsAuthentication;
|
||||
import com.minamp.bc.tls.TlsClientProtocol;
|
||||
import com.minamp.bc.tls.TlsCredentials;
|
||||
import com.minamp.bc.tls.TlsServerCertificate;
|
||||
import com.minamp.bc.tls.TlsSession;
|
||||
import com.minamp.bc.tls.CertificateRequest;
|
||||
import com.minamp.bc.tls.crypto.TlsCrypto;
|
||||
import com.minamp.bc.tls.crypto.impl.bc.BcTlsCrypto;
|
||||
|
||||
/**
|
||||
* Small HTTP/1.1 client with its own TLS 1.2/1.3 (Bouncy Castle), keep-alive pooling and
|
||||
* session resumption. Android 2.x's built-in SSL can't talk to plex.tv or *.plex.direct,
|
||||
* so every request in the app goes through here.
|
||||
*/
|
||||
public final class Http {
|
||||
|
||||
public static final String USER_AGENT = "Minamp/1.0";
|
||||
|
||||
/** Optional HTTP CONNECT proxy, only used when running the networking code on a desktop JVM. */
|
||||
public static String proxyHost;
|
||||
public static int proxyPort;
|
||||
|
||||
private static TlsCrypto crypto;
|
||||
private static final Map<String, TlsSession> sessions = new HashMap<String, TlsSession>();
|
||||
private static final Map<String, List<Conn>> pool = new HashMap<String, List<Conn>>();
|
||||
private static final long IDLE_MS = 25000;
|
||||
|
||||
private Http() {}
|
||||
|
||||
private static synchronized TlsCrypto crypto() {
|
||||
if (crypto == null) crypto = new BcTlsCrypto(new SecureRandom());
|
||||
return crypto;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ URL
|
||||
|
||||
public static final class Url {
|
||||
public final boolean https;
|
||||
public final String host;
|
||||
public final int port;
|
||||
public final String path; // includes query
|
||||
|
||||
public Url(String s) throws IOException {
|
||||
String lower = s.toLowerCase(Locale.US);
|
||||
int start;
|
||||
if (lower.startsWith("https://")) { https = true; start = 8; }
|
||||
else if (lower.startsWith("http://")) { https = false; start = 7; }
|
||||
else throw new IOException("Bad URL: " + s);
|
||||
int slash = s.indexOf('/', start);
|
||||
String hostPort = slash < 0 ? s.substring(start) : s.substring(start, slash);
|
||||
path = slash < 0 ? "/" : s.substring(slash);
|
||||
int colon = hostPort.lastIndexOf(':');
|
||||
if (colon > 0 && hostPort.indexOf(']') < colon) {
|
||||
host = hostPort.substring(0, colon);
|
||||
try { port = Integer.parseInt(hostPort.substring(colon + 1)); }
|
||||
catch (NumberFormatException e) { throw new IOException("Bad port in " + s); }
|
||||
} else {
|
||||
host = hostPort;
|
||||
port = https ? 443 : 80;
|
||||
}
|
||||
}
|
||||
|
||||
String key() { return (https ? "s:" : "p:") + host + ":" + port; }
|
||||
|
||||
String hostHeader() {
|
||||
boolean dflt = (https && port == 443) || (!https && port == 80);
|
||||
return dflt ? host : host + ":" + port;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ connection
|
||||
|
||||
static final class Conn {
|
||||
final String key;
|
||||
Socket socket;
|
||||
InputStream in;
|
||||
OutputStream out;
|
||||
TlsClientProtocol tls;
|
||||
long idleSince;
|
||||
|
||||
Conn(String key) { this.key = key; }
|
||||
|
||||
void close() {
|
||||
try { if (tls != null) tls.close(); } catch (Exception ignored) {}
|
||||
try { if (socket != null) socket.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private static Conn take(String key) {
|
||||
synchronized (pool) {
|
||||
List<Conn> list = pool.get(key);
|
||||
if (list == null) return null;
|
||||
long now = System.currentTimeMillis();
|
||||
while (!list.isEmpty()) {
|
||||
Conn c = list.remove(list.size() - 1);
|
||||
if (now - c.idleSince < IDLE_MS && !c.socket.isClosed()) return c;
|
||||
c.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void release(Conn c) {
|
||||
c.idleSince = System.currentTimeMillis();
|
||||
synchronized (pool) {
|
||||
List<Conn> list = pool.get(c.key);
|
||||
if (list == null) { list = new ArrayList<Conn>(); pool.put(c.key, list); }
|
||||
if (list.size() >= 4) { list.remove(0).close(); }
|
||||
list.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop all pooled connections (e.g. after the network changed). */
|
||||
public static void evictAll() {
|
||||
synchronized (pool) {
|
||||
for (Iterator<List<Conn>> it = pool.values().iterator(); it.hasNext();) {
|
||||
List<Conn> l = it.next();
|
||||
for (int i = 0; i < l.size(); i++) l.get(i).close();
|
||||
}
|
||||
pool.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static Conn connect(final Url u, int timeoutMs) throws IOException {
|
||||
Conn c = new Conn(u.key());
|
||||
Socket s = new Socket();
|
||||
boolean ok = false;
|
||||
try {
|
||||
if (proxyHost != null) {
|
||||
s.connect(new InetSocketAddress(proxyHost, proxyPort), timeoutMs);
|
||||
s.setSoTimeout(timeoutMs);
|
||||
OutputStream po = s.getOutputStream();
|
||||
String req = "CONNECT " + u.host + ":" + u.port + " HTTP/1.1\r\nHost: " + u.host + ":" + u.port + "\r\n\r\n";
|
||||
po.write(req.getBytes("ISO-8859-1"));
|
||||
po.flush();
|
||||
InputStream pi = s.getInputStream();
|
||||
String status = readLine(pi);
|
||||
if (status == null || status.indexOf(" 200") < 0) throw new IOException("Proxy refused: " + status);
|
||||
String line;
|
||||
while ((line = readLine(pi)) != null && line.length() > 0) { /* skip headers */ }
|
||||
} else {
|
||||
s.connect(new InetSocketAddress(u.host, u.port), timeoutMs);
|
||||
s.setSoTimeout(timeoutMs);
|
||||
}
|
||||
s.setTcpNoDelay(true);
|
||||
c.socket = s;
|
||||
if (u.https) {
|
||||
TlsClientProtocol proto = new TlsClientProtocol(s.getInputStream(), s.getOutputStream());
|
||||
proto.connect(new Client(crypto(), u.host, u.key()));
|
||||
c.tls = proto;
|
||||
c.in = new BufferedInputStream(proto.getInputStream(), 8192);
|
||||
c.out = new BufferedOutputStream(proto.getOutputStream(), 4096);
|
||||
} else {
|
||||
c.in = new BufferedInputStream(s.getInputStream(), 8192);
|
||||
c.out = new BufferedOutputStream(s.getOutputStream(), 4096);
|
||||
}
|
||||
ok = true;
|
||||
return c;
|
||||
} finally {
|
||||
if (!ok) { try { s.close(); } catch (Exception ignored) {} }
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Client extends DefaultTlsClient {
|
||||
private final String host;
|
||||
private final String sessionKey;
|
||||
|
||||
Client(TlsCrypto crypto, String host, String sessionKey) {
|
||||
super(crypto);
|
||||
this.host = host;
|
||||
this.sessionKey = sessionKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Vector getSNIServerNames() {
|
||||
if (CertVerifier.isIpLiteral(host)) return null;
|
||||
Vector v = new Vector();
|
||||
try {
|
||||
v.addElement(new ServerName(NameType.host_name, host.getBytes("US-ASCII")));
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TlsSession getSessionToResume() {
|
||||
synchronized (sessions) { return sessions.get(sessionKey); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyHandshakeComplete() throws IOException {
|
||||
super.notifyHandshakeComplete();
|
||||
TlsSession s = context.getSession();
|
||||
if (s != null && s.isResumable()) {
|
||||
synchronized (sessions) { sessions.put(sessionKey, s); }
|
||||
}
|
||||
}
|
||||
|
||||
public TlsAuthentication getAuthentication() throws IOException {
|
||||
return new TlsAuthentication() {
|
||||
public void notifyServerCertificate(TlsServerCertificate sc) throws IOException {
|
||||
com.minamp.bc.tls.Certificate cert = sc.getCertificate();
|
||||
List<byte[]> chain = new ArrayList<byte[]>();
|
||||
for (int i = 0; i < cert.getLength(); i++) chain.add(cert.getCertificateAt(i).getEncoded());
|
||||
CertVerifier.verify(chain, host);
|
||||
}
|
||||
|
||||
public TlsCredentials getClientCredentials(CertificateRequest req) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ request / response
|
||||
|
||||
public static final class Response {
|
||||
public final int code;
|
||||
public final String status;
|
||||
public final Map<String, String> headers; // lower-case names
|
||||
public final InputStream body;
|
||||
public final long contentLength; // -1 if unknown
|
||||
public final boolean chunked;
|
||||
|
||||
Response(int code, String status, Map<String, String> headers, InputStream body, long len, boolean chunked) {
|
||||
this.code = code; this.status = status; this.headers = headers; this.body = body;
|
||||
this.contentLength = len; this.chunked = chunked;
|
||||
}
|
||||
|
||||
public String header(String name) { return headers.get(name.toLowerCase(Locale.US)); }
|
||||
|
||||
public void close() {
|
||||
try { body.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
public byte[] readAll() throws IOException {
|
||||
try {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(contentLength > 0 && contentLength < 4000000 ? (int) contentLength : 8192);
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = body.read(buf)) > 0) bos.write(buf, 0, n);
|
||||
return bos.toByteArray();
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
public String readString() throws IOException {
|
||||
return new String(readAll(), "UTF-8");
|
||||
}
|
||||
}
|
||||
|
||||
public static Response get(String url, Map<String, String> headers, int timeoutMs) throws IOException {
|
||||
return request("GET", url, headers, null, timeoutMs);
|
||||
}
|
||||
|
||||
public static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
|
||||
for (int redirects = 0; ; redirects++) {
|
||||
Response r = requestOnce(method, url, headers, body, timeoutMs);
|
||||
if ((r.code == 301 || r.code == 302 || r.code == 307 || r.code == 308) && redirects < 4
|
||||
&& ("GET".equals(method) || "HEAD".equals(method))) {
|
||||
String loc = r.header("location");
|
||||
r.close();
|
||||
if (loc == null) throw new IOException("Redirect without location");
|
||||
if (loc.startsWith("/")) {
|
||||
Url u = new Url(url);
|
||||
loc = (u.https ? "https://" : "http://") + u.hostHeader() + loc;
|
||||
}
|
||||
url = loc;
|
||||
continue;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
private static Response requestOnce(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
|
||||
Url u = new Url(url);
|
||||
// A pooled connection may have been closed by the server; retry once on a fresh one.
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
Conn c = take(u.key());
|
||||
boolean pooled = c != null;
|
||||
if (c == null) c = connect(u, timeoutMs);
|
||||
try {
|
||||
c.socket.setSoTimeout(timeoutMs);
|
||||
writeRequest(c, u, method, headers, body);
|
||||
return readResponse(c, method);
|
||||
} catch (IOException e) {
|
||||
c.close();
|
||||
if (!pooled) throw e;
|
||||
}
|
||||
}
|
||||
throw new IOException("Request failed");
|
||||
}
|
||||
|
||||
private static void writeRequest(Conn c, Url u, String method, Map<String, String> headers, byte[] body) throws IOException {
|
||||
StringBuilder sb = new StringBuilder(512);
|
||||
sb.append(method).append(' ').append(u.path).append(" HTTP/1.1\r\n");
|
||||
sb.append("Host: ").append(u.hostHeader()).append("\r\n");
|
||||
boolean hasUa = false;
|
||||
if (headers != null) {
|
||||
for (Map.Entry<String, String> e : headers.entrySet()) {
|
||||
if (e.getValue() == null) continue;
|
||||
if ("user-agent".equalsIgnoreCase(e.getKey())) hasUa = true;
|
||||
sb.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n");
|
||||
}
|
||||
}
|
||||
if (!hasUa) sb.append("User-Agent: ").append(USER_AGENT).append("\r\n");
|
||||
sb.append("Connection: keep-alive\r\n");
|
||||
if (body != null || "POST".equals(method) || "PUT".equals(method)) {
|
||||
sb.append("Content-Length: ").append(body == null ? 0 : body.length).append("\r\n");
|
||||
}
|
||||
sb.append("\r\n");
|
||||
c.out.write(sb.toString().getBytes("UTF-8"));
|
||||
if (body != null) c.out.write(body);
|
||||
c.out.flush();
|
||||
}
|
||||
|
||||
private static Response readResponse(Conn c, String method) throws IOException {
|
||||
String statusLine = readLine(c.in);
|
||||
if (statusLine == null) throw new IOException("Connection closed");
|
||||
// HTTP/1.1 200 OK
|
||||
int sp = statusLine.indexOf(' ');
|
||||
if (sp < 0) throw new IOException("Bad status line");
|
||||
int sp2 = statusLine.indexOf(' ', sp + 1);
|
||||
int code;
|
||||
try {
|
||||
code = Integer.parseInt(sp2 < 0 ? statusLine.substring(sp + 1).trim() : statusLine.substring(sp + 1, sp2));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IOException("Bad status line");
|
||||
}
|
||||
boolean http10 = statusLine.startsWith("HTTP/1.0");
|
||||
Map<String, String> h = new LinkedHashMap<String, String>();
|
||||
String line;
|
||||
while ((line = readLine(c.in)) != null && line.length() > 0) {
|
||||
int colon = line.indexOf(':');
|
||||
if (colon <= 0) continue;
|
||||
String name = line.substring(0, colon).trim().toLowerCase(Locale.US);
|
||||
String val = line.substring(colon + 1).trim();
|
||||
String prev = h.get(name);
|
||||
h.put(name, prev == null ? val : prev + ", " + val);
|
||||
}
|
||||
String te = h.get("transfer-encoding");
|
||||
String cl = h.get("content-length");
|
||||
String conn = h.get("connection");
|
||||
boolean keepAlive = !http10 && (conn == null || conn.toLowerCase(Locale.US).indexOf("close") < 0);
|
||||
boolean chunked = te != null && te.toLowerCase(Locale.US).indexOf("chunked") >= 0;
|
||||
long len = -1;
|
||||
if (!chunked && cl != null) {
|
||||
try { len = Long.parseLong(cl.trim()); } catch (NumberFormatException ignored) {}
|
||||
}
|
||||
InputStream body;
|
||||
boolean noBody = "HEAD".equals(method) || code == 204 || code == 304 || (code >= 100 && code < 200);
|
||||
if (noBody) {
|
||||
body = new FixedStream(c, 0, keepAlive);
|
||||
len = "HEAD".equals(method) ? len : 0;
|
||||
} else if (chunked) {
|
||||
body = new ChunkedStream(c, keepAlive);
|
||||
} else if (len >= 0) {
|
||||
body = new FixedStream(c, len, keepAlive);
|
||||
} else {
|
||||
body = new EofStream(c);
|
||||
}
|
||||
return new Response(code, statusLine, h, body, len, chunked);
|
||||
}
|
||||
|
||||
static String readLine(InputStream in) throws IOException {
|
||||
StringBuilder sb = new StringBuilder(64);
|
||||
int b;
|
||||
while ((b = in.read()) >= 0) {
|
||||
if (b == '\n') {
|
||||
int n = sb.length();
|
||||
if (n > 0 && sb.charAt(n - 1) == '\r') sb.setLength(n - 1);
|
||||
return sb.toString();
|
||||
}
|
||||
sb.append((char) b);
|
||||
if (sb.length() > 16384) throw new IOException("Header line too long");
|
||||
}
|
||||
return sb.length() == 0 ? null : sb.toString();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ body streams
|
||||
|
||||
private static final class FixedStream extends InputStream {
|
||||
private final Conn c; private long left; private final boolean keepAlive; private boolean closed;
|
||||
|
||||
FixedStream(Conn c, long len, boolean keepAlive) {
|
||||
this.c = c; this.left = len; this.keepAlive = keepAlive;
|
||||
if (len == 0) finish(true);
|
||||
}
|
||||
|
||||
private void finish(boolean complete) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (complete && keepAlive) release(c); else c.close();
|
||||
}
|
||||
|
||||
@Override public int read() throws IOException {
|
||||
byte[] one = new byte[1];
|
||||
int n = read(one, 0, 1);
|
||||
return n <= 0 ? -1 : one[0] & 255;
|
||||
}
|
||||
|
||||
@Override public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (left <= 0) { finish(true); return -1; }
|
||||
if (closed) return -1;
|
||||
int n = c.in.read(b, off, (int) Math.min(len, left));
|
||||
if (n < 0) { finish(false); throw new IOException("Unexpected end of stream"); }
|
||||
left -= n;
|
||||
if (left == 0) finish(true);
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override public int available() throws IOException {
|
||||
if (closed) return 0;
|
||||
return (int) Math.min(c.in.available(), left);
|
||||
}
|
||||
|
||||
@Override public void close() { finish(left == 0); }
|
||||
}
|
||||
|
||||
private static final class ChunkedStream extends InputStream {
|
||||
private final Conn c; private final boolean keepAlive;
|
||||
private long chunkLeft = 0; private boolean eof; private boolean closed;
|
||||
|
||||
ChunkedStream(Conn c, boolean keepAlive) { this.c = c; this.keepAlive = keepAlive; }
|
||||
|
||||
private void finish(boolean complete) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (complete && keepAlive) release(c); else c.close();
|
||||
}
|
||||
|
||||
private boolean nextChunk() throws IOException {
|
||||
if (eof) return false;
|
||||
String line = readLine(c.in);
|
||||
if (line == null) throw new IOException("Unexpected end of chunked stream");
|
||||
if (line.length() == 0) { line = readLine(c.in); if (line == null) throw new IOException("Bad chunk"); }
|
||||
int semi = line.indexOf(';');
|
||||
if (semi >= 0) line = line.substring(0, semi);
|
||||
try { chunkLeft = Long.parseLong(line.trim(), 16); }
|
||||
catch (NumberFormatException e) { throw new IOException("Bad chunk size"); }
|
||||
if (chunkLeft == 0) {
|
||||
String t;
|
||||
while ((t = readLine(c.in)) != null && t.length() > 0) { /* trailers */ }
|
||||
eof = true;
|
||||
finish(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public int read() throws IOException {
|
||||
byte[] one = new byte[1];
|
||||
int n = read(one, 0, 1);
|
||||
return n <= 0 ? -1 : one[0] & 255;
|
||||
}
|
||||
|
||||
@Override public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (closed) return -1;
|
||||
if (chunkLeft == 0) {
|
||||
if (!nextChunk()) return -1;
|
||||
}
|
||||
int n = c.in.read(b, off, (int) Math.min(len, chunkLeft));
|
||||
if (n < 0) { finish(false); throw new IOException("Unexpected end of chunk"); }
|
||||
chunkLeft -= n;
|
||||
if (chunkLeft == 0) {
|
||||
// consume CRLF after chunk data
|
||||
String crlf = readLine(c.in);
|
||||
if (crlf == null) { finish(false); }
|
||||
else if (crlf.length() != 0) { finish(false); throw new IOException("Bad chunk terminator"); }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override public int available() throws IOException {
|
||||
if (closed) return 0;
|
||||
return (int) Math.min(c.in.available(), chunkLeft);
|
||||
}
|
||||
|
||||
@Override public void close() { finish(eof); }
|
||||
}
|
||||
|
||||
private static final class EofStream extends InputStream {
|
||||
private final Conn c; private boolean closed;
|
||||
EofStream(Conn c) { this.c = c; }
|
||||
|
||||
@Override public int read() throws IOException {
|
||||
if (closed) return -1;
|
||||
int b = c.in.read();
|
||||
if (b < 0) close();
|
||||
return b;
|
||||
}
|
||||
|
||||
@Override public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (closed) return -1;
|
||||
int n = c.in.read(b, off, len);
|
||||
if (n < 0) close();
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (!closed) { closed = true; c.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Close one in-flight response's socket from another thread (used to abort streams). */
|
||||
public static void abort(Response r) {
|
||||
if (r == null) return;
|
||||
InputStream b = r.body;
|
||||
Conn c = null;
|
||||
if (b instanceof FixedStream) c = ((FixedStream) b).c;
|
||||
else if (b instanceof ChunkedStream) c = ((ChunkedStream) b).c;
|
||||
else if (b instanceof EofStream) c = ((EofStream) b).c;
|
||||
if (c != null) {
|
||||
try { c.socket.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.minamp.net;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Loopback HTTP server that hands the platform MediaPlayer a plain http://127.0.0.1 URL and
|
||||
* fetches the real stream through {@link Http} (our own TLS, plex.direct, relay...).
|
||||
* It also turns chunked transcoder output into a simple read-until-close response, which
|
||||
* Android 2.x's media stack copes with much better.
|
||||
*/
|
||||
public final class StreamProxy implements Runnable {
|
||||
|
||||
private ServerSocket server;
|
||||
private Thread thread;
|
||||
private volatile boolean running;
|
||||
private int counter;
|
||||
private final Map<String, String> routes = new HashMap<String, String>();
|
||||
private final Map<String, List<Http.Response>> live = new HashMap<String, List<Http.Response>>();
|
||||
|
||||
/** Called with how many bytes have arrived for a route (for buffering UI / diagnostics). */
|
||||
public interface Listener { void onError(String routeId, String message); }
|
||||
public volatile Listener listener;
|
||||
|
||||
public synchronized void start() throws IOException {
|
||||
if (running) return;
|
||||
server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"));
|
||||
running = true;
|
||||
thread = new Thread(this, "stream-proxy");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public synchronized void shutdown() {
|
||||
running = false;
|
||||
try { if (server != null) server.close(); } catch (IOException ignored) {}
|
||||
synchronized (routes) {
|
||||
for (String id : new ArrayList<String>(live.keySet())) abortLocked(id);
|
||||
routes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public int port() { return server == null ? -1 : server.getLocalPort(); }
|
||||
|
||||
/** @return the local URL to give MediaPlayer, ending in a fake extension it may sniff. */
|
||||
public String register(String id, String remoteUrl, String ext) {
|
||||
synchronized (routes) {
|
||||
routes.put(id, remoteUrl);
|
||||
}
|
||||
return "http://127.0.0.1:" + port() + "/s/" + id + "/stream." + ext;
|
||||
}
|
||||
|
||||
public String newId() {
|
||||
synchronized (routes) { return Integer.toString(++counter) + "x" + Long.toString(System.currentTimeMillis() % 100000, 36); }
|
||||
}
|
||||
|
||||
/** Forget a route and cut any upstream connections still feeding it. */
|
||||
public void unregister(String id) {
|
||||
if (id == null) return;
|
||||
synchronized (routes) {
|
||||
routes.remove(id);
|
||||
abortLocked(id);
|
||||
}
|
||||
}
|
||||
|
||||
private void abortLocked(String id) {
|
||||
List<Http.Response> l = live.remove(id);
|
||||
if (l == null) return;
|
||||
for (int i = 0; i < l.size(); i++) Http.abort(l.get(i));
|
||||
}
|
||||
|
||||
public void run() {
|
||||
while (running) {
|
||||
final Socket s;
|
||||
try {
|
||||
s = server.accept();
|
||||
} catch (IOException e) {
|
||||
if (!running) return;
|
||||
continue;
|
||||
}
|
||||
Thread t = new Thread("proxy-conn") {
|
||||
public void run() { handle(s); }
|
||||
};
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void handle(Socket s) {
|
||||
Http.Response up = null;
|
||||
String id = null;
|
||||
try {
|
||||
s.setSoTimeout(60000);
|
||||
InputStream in = new BufferedInputStream(s.getInputStream(), 2048);
|
||||
OutputStream out = s.getOutputStream();
|
||||
String reqLine = Http.readLine(in);
|
||||
if (reqLine == null) return;
|
||||
Map<String, String> reqHeaders = new HashMap<String, String>();
|
||||
String line;
|
||||
while ((line = Http.readLine(in)) != null && line.length() > 0) {
|
||||
int c = line.indexOf(':');
|
||||
if (c > 0) reqHeaders.put(line.substring(0, c).trim().toLowerCase(Locale.US), line.substring(c + 1).trim());
|
||||
}
|
||||
String[] parts = reqLine.split(" ");
|
||||
String method = parts.length > 0 ? parts[0] : "GET";
|
||||
String path = parts.length > 1 ? parts[1] : "/";
|
||||
// /s/<id>/stream.mp3
|
||||
if (path.startsWith("/s/")) {
|
||||
int slash = path.indexOf('/', 3);
|
||||
id = slash < 0 ? path.substring(3) : path.substring(3, slash);
|
||||
}
|
||||
String target;
|
||||
synchronized (routes) { target = id == null ? null : routes.get(id); }
|
||||
if (target == null) {
|
||||
out.write("HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes("ISO-8859-1"));
|
||||
out.flush();
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> h = new LinkedHashMap<String, String>();
|
||||
h.put("Accept", "*/*");
|
||||
String range = reqHeaders.get("range");
|
||||
if (range != null) h.put("Range", range);
|
||||
boolean head = "HEAD".equals(method);
|
||||
up = Http.request(head ? "HEAD" : "GET", target, h, null, 30000);
|
||||
synchronized (routes) {
|
||||
if (!routes.containsKey(id)) { Http.abort(up); return; }
|
||||
List<Http.Response> l = live.get(id);
|
||||
if (l == null) { l = new ArrayList<Http.Response>(); live.put(id, l); }
|
||||
l.add(up);
|
||||
}
|
||||
|
||||
StringBuilder resp = new StringBuilder(256);
|
||||
boolean known = up.contentLength >= 0 && !up.chunked;
|
||||
resp.append(known ? "HTTP/1.1 " : "HTTP/1.0 ").append(up.code).append(' ').append(reason(up.code)).append("\r\n");
|
||||
String ct = up.header("content-type");
|
||||
if (ct == null || ct.startsWith("application/octet") || ct.startsWith("text/")) ct = guessType(path);
|
||||
if (up.code >= 400) ct = "text/plain";
|
||||
resp.append("Content-Type: ").append(ct).append("\r\n");
|
||||
if (known) resp.append("Content-Length: ").append(up.contentLength).append("\r\n");
|
||||
String cr = up.header("content-range");
|
||||
if (cr != null) resp.append("Content-Range: ").append(cr).append("\r\n");
|
||||
String ar = up.header("accept-ranges");
|
||||
if (ar != null && known) resp.append("Accept-Ranges: ").append(ar).append("\r\n");
|
||||
resp.append("Connection: close\r\n\r\n");
|
||||
out.write(resp.toString().getBytes("ISO-8859-1"));
|
||||
out.flush();
|
||||
if (up.code >= 400) {
|
||||
Listener l = listener;
|
||||
if (l != null) l.onError(id, "Server said " + up.code);
|
||||
}
|
||||
if (head) { up.close(); return; }
|
||||
|
||||
byte[] buf = new byte[16384];
|
||||
int n;
|
||||
while ((n = up.body.read(buf)) > 0) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
out.flush();
|
||||
up.close();
|
||||
synchronized (routes) {
|
||||
List<Http.Response> l = live.get(id);
|
||||
if (l != null) l.remove(up);
|
||||
}
|
||||
up = null;
|
||||
} catch (IOException e) {
|
||||
// player went away, track skipped, or the network dropped: just close
|
||||
} finally {
|
||||
if (up != null) Http.abort(up);
|
||||
if (id != null && up != null) {
|
||||
synchronized (routes) {
|
||||
List<Http.Response> l = live.get(id);
|
||||
if (l != null) l.remove(up);
|
||||
}
|
||||
}
|
||||
try { s.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private static String reason(int code) {
|
||||
switch (code) {
|
||||
case 200: return "OK";
|
||||
case 206: return "Partial Content";
|
||||
case 404: return "Not Found";
|
||||
case 416: return "Range Not Satisfiable";
|
||||
default: return code < 400 ? "OK" : "Error";
|
||||
}
|
||||
}
|
||||
|
||||
private static String guessType(String path) {
|
||||
String p = path.toLowerCase(Locale.US);
|
||||
if (p.endsWith(".m4a") || p.endsWith(".mp4")) return "audio/mp4";
|
||||
if (p.endsWith(".ogg")) return "audio/ogg";
|
||||
return "audio/mpeg";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.minamp.plex;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.minamp.util.Json;
|
||||
|
||||
/** One Plex metadata item: artist, album, track, playlist (or a hub header in search results). */
|
||||
public final class Item {
|
||||
public static final int ARTIST = 1, ALBUM = 2, TRACK = 3, PLAYLIST = 4, HEADER = 5, OTHER = 0;
|
||||
|
||||
public int type;
|
||||
public String ratingKey = "";
|
||||
public String key = ""; // children path
|
||||
public String title = "";
|
||||
public String artist = ""; // track/album artist
|
||||
public String album = ""; // track's album
|
||||
public String thumb = ""; // art path on the server
|
||||
public int year;
|
||||
public int index; // track number
|
||||
public long duration; // ms
|
||||
public int leafCount; // tracks in album/playlist
|
||||
public double userRating; // 0..10, -1 unrated
|
||||
public String albumKey = ""; // parentRatingKey
|
||||
public String artistKey = ""; // grandparentRatingKey (tracks) / parentRatingKey (albums)
|
||||
// media (tracks only)
|
||||
public String partKey = "";
|
||||
public String codec = "";
|
||||
public String container = "";
|
||||
public int bitrate; // kbps
|
||||
public long partSize;
|
||||
public String playlistItemId = "";
|
||||
|
||||
public boolean isContainer() { return type == ARTIST || type == ALBUM || type == PLAYLIST; }
|
||||
public boolean loved() { return userRating >= 9.5; }
|
||||
|
||||
public static int typeOf(String t) {
|
||||
if ("artist".equals(t)) return ARTIST;
|
||||
if ("album".equals(t)) return ALBUM;
|
||||
if ("track".equals(t)) return TRACK;
|
||||
if ("playlist".equals(t)) return PLAYLIST;
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
public static Item fromPlex(Json.Obj o) {
|
||||
Item it = new Item();
|
||||
it.type = typeOf(o.optString("type"));
|
||||
it.ratingKey = o.optString("ratingKey");
|
||||
it.key = o.optString("key");
|
||||
it.title = o.optString("title");
|
||||
it.year = o.optInt("year", 0);
|
||||
it.index = o.optInt("index", 0);
|
||||
it.duration = o.optLong("duration", 0);
|
||||
it.leafCount = o.optInt("leafCount", 0);
|
||||
it.userRating = o.optDouble("userRating", -1);
|
||||
switch (it.type) {
|
||||
case TRACK:
|
||||
it.artist = o.optString("originalTitle", "");
|
||||
if (it.artist.length() == 0) it.artist = o.optString("grandparentTitle");
|
||||
it.album = o.optString("parentTitle");
|
||||
it.thumb = o.optString("parentThumb", "");
|
||||
if (it.thumb.length() == 0) it.thumb = o.optString("thumb", "");
|
||||
if (it.thumb.length() == 0) it.thumb = o.optString("grandparentThumb", "");
|
||||
it.albumKey = o.optString("parentRatingKey");
|
||||
it.artistKey = o.optString("grandparentRatingKey");
|
||||
it.playlistItemId = o.optString("playlistItemID");
|
||||
Json.Arr media = o.optArr("Media");
|
||||
Json.Obj m = media == null ? null : media.optObj(0);
|
||||
if (m != null) {
|
||||
it.codec = m.optString("audioCodec");
|
||||
it.container = m.optString("container");
|
||||
it.bitrate = m.optInt("bitrate", 0);
|
||||
if (it.duration == 0) it.duration = m.optLong("duration", 0);
|
||||
Json.Arr parts = m.optArr("Part");
|
||||
Json.Obj p = parts == null ? null : parts.optObj(0);
|
||||
if (p != null) {
|
||||
it.partKey = p.optString("key");
|
||||
it.partSize = p.optLong("size", 0);
|
||||
if (it.container.length() == 0) it.container = p.optString("container");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ALBUM:
|
||||
it.artist = o.optString("parentTitle");
|
||||
it.thumb = o.optString("thumb");
|
||||
it.artistKey = o.optString("parentRatingKey");
|
||||
break;
|
||||
case PLAYLIST:
|
||||
it.thumb = o.optString("composite", "");
|
||||
if (it.thumb.length() == 0) it.thumb = o.optString("thumb", "");
|
||||
break;
|
||||
default:
|
||||
it.thumb = o.optString("thumb");
|
||||
}
|
||||
return it;
|
||||
}
|
||||
|
||||
public static List<Item> listFrom(Json.Obj container) {
|
||||
List<Item> out = new ArrayList<Item>();
|
||||
if (container == null) return out;
|
||||
addAll(out, container.optArr("Metadata"));
|
||||
addAll(out, container.optArr("Directory"));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void addAll(List<Item> out, Json.Arr arr) {
|
||||
if (arr == null) return;
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
Json.Obj o = arr.optObj(i);
|
||||
if (o == null) continue;
|
||||
Item it = fromPlex(o);
|
||||
if (it.type != OTHER) out.add(it);
|
||||
}
|
||||
}
|
||||
|
||||
public String subtitle() {
|
||||
switch (type) {
|
||||
case TRACK: return artist + (album.length() > 0 ? " · " + album : "");
|
||||
case ALBUM: return artist + (year > 0 ? " · " + year : "");
|
||||
case PLAYLIST: return leafCount + (leafCount == 1 ? " track" : " tracks");
|
||||
case ARTIST: return "Artist";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ persistence
|
||||
|
||||
public Json.Obj toJson() {
|
||||
Json.Obj o = new Json.Obj();
|
||||
o.put("t", Long.valueOf(type)).put("rk", ratingKey).put("k", key).put("ti", title)
|
||||
.put("ar", artist).put("al", album).put("th", thumb).put("y", Long.valueOf(year))
|
||||
.put("i", Long.valueOf(index)).put("d", Long.valueOf(duration)).put("lc", Long.valueOf(leafCount))
|
||||
.put("ur", Double.valueOf(userRating)).put("ak", albumKey).put("rk2", artistKey)
|
||||
.put("pk", partKey).put("c", codec).put("ct", container).put("br", Long.valueOf(bitrate))
|
||||
.put("ps", Long.valueOf(partSize));
|
||||
return o;
|
||||
}
|
||||
|
||||
public static Item fromJson(Json.Obj o) {
|
||||
Item it = new Item();
|
||||
it.type = o.optInt("t", 0);
|
||||
it.ratingKey = o.optString("rk");
|
||||
it.key = o.optString("k");
|
||||
it.title = o.optString("ti");
|
||||
it.artist = o.optString("ar");
|
||||
it.album = o.optString("al");
|
||||
it.thumb = o.optString("th");
|
||||
it.year = o.optInt("y", 0);
|
||||
it.index = o.optInt("i", 0);
|
||||
it.duration = o.optLong("d", 0);
|
||||
it.leafCount = o.optInt("lc", 0);
|
||||
it.userRating = o.optDouble("ur", -1);
|
||||
it.albumKey = o.optString("ak");
|
||||
it.artistKey = o.optString("rk2");
|
||||
it.partKey = o.optString("pk");
|
||||
it.codec = o.optString("c");
|
||||
it.container = o.optString("ct");
|
||||
it.bitrate = o.optInt("br", 0);
|
||||
it.partSize = o.optLong("ps", 0);
|
||||
return it;
|
||||
}
|
||||
|
||||
public String serialize() { return toJson().toString(); }
|
||||
|
||||
public static Item deserialize(String s) { return fromJson(Json.parseObj(s)); }
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.minamp.plex;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import com.minamp.util.Json;
|
||||
|
||||
/** Paths into one music library section, plus the playback-URL decisions. */
|
||||
public final class Library {
|
||||
|
||||
public final Server server;
|
||||
public final String section;
|
||||
|
||||
public Library(Server server, String section) {
|
||||
this.server = server;
|
||||
this.section = section;
|
||||
}
|
||||
|
||||
private static String page(int start, int size) {
|
||||
return "&X-Plex-Container-Start=" + start + "&X-Plex-Container-Size=" + size;
|
||||
}
|
||||
|
||||
public String artistsPath(int start, int size) {
|
||||
return "/library/sections/" + section + "/all?type=8&sort=titleSort" + page(start, size);
|
||||
}
|
||||
|
||||
public String albumsPath(String sort, int start, int size) {
|
||||
return "/library/sections/" + section + "/all?type=9&sort=" + sort + page(start, size);
|
||||
}
|
||||
|
||||
public String recentlyAddedPath(int size) {
|
||||
return "/library/sections/" + section + "/all?type=9&sort=addedAt:desc" + page(0, size);
|
||||
}
|
||||
|
||||
public String recentlyPlayedPath(int size) {
|
||||
return "/library/sections/" + section + "/all?type=9&sort=lastViewedAt:desc&viewCount>>=0" + page(0, size);
|
||||
}
|
||||
|
||||
public String playlistsPath() {
|
||||
return "/playlists?playlistType=audio§ionID=" + section;
|
||||
}
|
||||
|
||||
public String searchPath(String query) {
|
||||
return "/hubs/search?query=" + Plex.enc(query) + "§ionId=" + section + "&limit=12";
|
||||
}
|
||||
|
||||
/** Search results grouped as [HEADER, items..., HEADER, items...] (artists, albums, tracks). */
|
||||
public List<Item> search(String query) throws IOException {
|
||||
Json.Obj mc = server.get(searchPath(query));
|
||||
List<Item> out = new ArrayList<Item>();
|
||||
Json.Arr hubs = mc == null ? null : mc.optArr("Hub");
|
||||
if (hubs == null) return out;
|
||||
String[] order = {"artist", "album", "track", "playlist"};
|
||||
for (int o = 0; o < order.length; o++) {
|
||||
for (int i = 0; i < hubs.size(); i++) {
|
||||
Json.Obj hub = hubs.optObj(i);
|
||||
if (hub == null || !order[o].equals(hub.optString("type"))) continue;
|
||||
List<Item> items = Item.listFrom(hub);
|
||||
if (items.isEmpty()) continue;
|
||||
Item header = new Item();
|
||||
header.type = Item.HEADER;
|
||||
header.title = hub.optString("title", order[o]);
|
||||
out.add(header);
|
||||
out.addAll(items);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static String childrenPath(Item it) {
|
||||
if (it.type == Item.PLAYLIST) return "/playlists/" + it.ratingKey + "/items";
|
||||
return "/library/metadata/" + it.ratingKey + "/children";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ radios / mixes
|
||||
|
||||
/** "Library radio": a random slice of every track in the library. */
|
||||
public List<Item> libraryRadio() throws IOException {
|
||||
return server.items("/library/sections/" + section + "/all?type=10&sort=random" + page(0, 100));
|
||||
}
|
||||
|
||||
/** Tracks rated four stars or better (and loved tracks), shuffled. */
|
||||
public List<Item> topRatedRadio() throws IOException {
|
||||
return server.items("/library/sections/" + section + "/all?type=10&userRating>>=7&sort=random" + page(0, 100));
|
||||
}
|
||||
|
||||
/** Tracks from albums released in a random decade that the library actually has. */
|
||||
public List<Item> timeTravelRadio(int decade) throws IOException {
|
||||
List<Item> tracks;
|
||||
try {
|
||||
tracks = server.items("/library/sections/" + section + "/all?type=10&album.decade=" + decade + "&sort=random" + page(0, 100));
|
||||
} catch (Plex.PlexException e) {
|
||||
tracks = new ArrayList<Item>();
|
||||
}
|
||||
if (!tracks.isEmpty()) return tracks;
|
||||
// Older servers don't filter tracks by album decade: build it from random albums instead.
|
||||
List<Item> albums = server.items("/library/sections/" + section + "/all?type=9&decade=" + decade + "&sort=random" + page(0, 8));
|
||||
for (int i = 0; i < albums.size(); i++) tracks.addAll(tracksOf(albums.get(i)));
|
||||
shuffle(tracks);
|
||||
return tracks;
|
||||
}
|
||||
|
||||
public List<Integer> decades() throws IOException {
|
||||
Json.Obj mc = server.get("/library/sections/" + section + "/decade?type=9");
|
||||
List<Integer> out = new ArrayList<Integer>();
|
||||
Json.Arr d = mc == null ? null : mc.optArr("Directory");
|
||||
if (d == null) return out;
|
||||
for (int i = 0; i < d.size(); i++) {
|
||||
Json.Obj o = d.optObj(i);
|
||||
if (o == null) continue;
|
||||
int v = o.optInt("key", 0);
|
||||
if (v > 0) out.add(Integer.valueOf(v));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every track for an album, artist or playlist, in order. */
|
||||
public List<Item> tracksOf(Item it) throws IOException {
|
||||
String path;
|
||||
switch (it.type) {
|
||||
case Item.TRACK: {
|
||||
List<Item> one = new ArrayList<Item>();
|
||||
one.add(it);
|
||||
return one;
|
||||
}
|
||||
case Item.ALBUM: path = "/library/metadata/" + it.ratingKey + "/children"; break;
|
||||
case Item.ARTIST: path = "/library/metadata/" + it.ratingKey + "/allLeaves"; break;
|
||||
case Item.PLAYLIST: path = "/playlists/" + it.ratingKey + "/items" + "?X-Plex-Container-Start=0&X-Plex-Container-Size=1000"; break;
|
||||
default: return new ArrayList<Item>();
|
||||
}
|
||||
List<Item> all = server.items(path);
|
||||
List<Item> tracks = new ArrayList<Item>();
|
||||
for (int i = 0; i < all.size(); i++) if (all.get(i).type == Item.TRACK) tracks.add(all.get(i));
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/** Fills in media info for a track that came from somewhere without it (e.g. search). */
|
||||
public void ensureMedia(Item t) throws IOException {
|
||||
if (t.partKey.length() > 0) return;
|
||||
List<Item> l = server.items("/library/metadata/" + t.ratingKey);
|
||||
if (!l.isEmpty()) {
|
||||
Item full = l.get(0);
|
||||
t.partKey = full.partKey; t.codec = full.codec; t.container = full.container;
|
||||
t.bitrate = full.bitrate; t.partSize = full.partSize;
|
||||
if (t.duration == 0) t.duration = full.duration;
|
||||
}
|
||||
if (t.partKey.length() == 0) throw new IOException("Track has no playable media");
|
||||
}
|
||||
|
||||
public static void shuffle(List<Item> l) {
|
||||
Collections.shuffle(l, new Random());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ streaming
|
||||
|
||||
/** What Android 2.2/2.3's media framework can decode itself. */
|
||||
public static boolean canDirectPlay(Item t, int maxKbps) {
|
||||
String c = t.codec.toLowerCase();
|
||||
String k = t.container.toLowerCase();
|
||||
boolean ok = ("mp3".equals(c) && ("mp3".equals(k) || k.length() == 0))
|
||||
|| ("aac".equals(c) && ("mp4".equals(k) || "m4a".equals(k) || "m4b".equals(k)))
|
||||
|| ("vorbis".equals(c) && "ogg".equals(k));
|
||||
if (!ok) return false;
|
||||
if (maxKbps <= 0) return true;
|
||||
return t.bitrate > 0 && t.bitrate <= maxKbps + 16;
|
||||
}
|
||||
|
||||
public String directUrl(Item t) throws IOException {
|
||||
return server.url(t.partKey);
|
||||
}
|
||||
|
||||
/** MP3 transcode over plain progressive HTTP, starting at offsetMs. */
|
||||
public String transcodeUrl(Item t, int kbps, long offsetMs, String session) throws IOException {
|
||||
if (kbps <= 0) kbps = 320;
|
||||
String profile = "add-transcode-target(type=musicProfile&context=streaming&protocol=http&container=mp3&audioCodec=mp3)"
|
||||
+ "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.bitrate&value=" + kbps + ")"
|
||||
+ "+add-limitation(scope=musicCodec&scopeName=mp3&type=upperBound&name=audio.channels&value=2)";
|
||||
String path = "/music/:/transcode/universal/start.mp3"
|
||||
+ "?path=" + Plex.enc("/library/metadata/" + t.ratingKey)
|
||||
+ "&mediaIndex=0&partIndex=0&protocol=http&hasMDE=1&fastSeek=1"
|
||||
+ "&directPlay=0&directStream=0&directStreamAudio=0"
|
||||
+ "&musicBitrate=" + kbps + "&maxAudioBitrate=" + kbps
|
||||
+ "&offset=" + (offsetMs / 1000)
|
||||
+ "&session=" + Plex.enc(session)
|
||||
+ "&location=" + (server.isLocal() ? "lan" : "wan")
|
||||
+ "&X-Plex-Session-Identifier=" + Plex.enc(session)
|
||||
+ "&X-Plex-Client-Identifier=" + Plex.enc(Plex.clientId)
|
||||
+ "&X-Plex-Product=" + Plex.enc(Plex.product)
|
||||
+ "&X-Plex-Version=" + Plex.enc(Plex.version)
|
||||
+ "&X-Plex-Platform=Generic"
|
||||
+ "&X-Plex-Device=" + Plex.enc(Plex.device)
|
||||
+ "&X-Plex-Client-Profile-Extra=" + Plex.enc(profile);
|
||||
return server.url(path);
|
||||
}
|
||||
|
||||
public void stopTranscode(String session) {
|
||||
try {
|
||||
server.fetchQuiet("/transcode/universal/stop?session=" + Plex.enc(session));
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ reporting
|
||||
|
||||
public void timeline(Item t, String state, long timeMs) {
|
||||
try {
|
||||
server.fetchQuiet("/:/timeline?ratingKey=" + t.ratingKey
|
||||
+ "&key=" + Plex.enc("/library/metadata/" + t.ratingKey)
|
||||
+ "&state=" + state + "&time=" + timeMs + "&playbackTime=" + timeMs
|
||||
+ "&duration=" + t.duration + "&hasMDE=1");
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
public void scrobble(Item t) {
|
||||
try {
|
||||
server.fetchQuiet("/:/scrobble?identifier=com.plexapp.plugins.library&key=" + Plex.enc(t.ratingKey));
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
public void rate(Item t, double rating) throws IOException {
|
||||
server.put("/:/rate?identifier=com.plexapp.plugins.library&key=" + Plex.enc(t.ratingKey) + "&rating=" + (int) rating);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.minamp.plex;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.minamp.net.Http;
|
||||
import com.minamp.util.Json;
|
||||
|
||||
/** Client identity + plex.tv account calls (PIN sign-in, server discovery). */
|
||||
public final class Plex {
|
||||
|
||||
public static String product = "Minamp";
|
||||
public static String version = "1.0";
|
||||
public static String clientId = "minamp-dev";
|
||||
public static String platform = "Android";
|
||||
public static String platformVersion = "2.3";
|
||||
public static String device = "Galaxy Mini";
|
||||
public static String deviceName = "Galaxy Mini";
|
||||
|
||||
private static final String TV = "https://plex.tv"; // the test harness maps plex.tv to a mock
|
||||
|
||||
private Plex() {}
|
||||
|
||||
public static Map<String, String> headers(String token) {
|
||||
Map<String, String> h = new LinkedHashMap<String, String>();
|
||||
h.put("Accept", "application/json");
|
||||
h.put("X-Plex-Product", product);
|
||||
h.put("X-Plex-Version", version);
|
||||
h.put("X-Plex-Client-Identifier", clientId);
|
||||
h.put("X-Plex-Platform", platform);
|
||||
h.put("X-Plex-Platform-Version", platformVersion);
|
||||
h.put("X-Plex-Device", device);
|
||||
h.put("X-Plex-Device-Name", deviceName);
|
||||
h.put("X-Plex-Provides", "player");
|
||||
if (token != null && token.length() > 0) h.put("X-Plex-Token", token);
|
||||
return h;
|
||||
}
|
||||
|
||||
public static String enc(String s) {
|
||||
try { return URLEncoder.encode(s, "UTF-8").replace("+", "%20"); }
|
||||
catch (UnsupportedEncodingException e) { return s; }
|
||||
}
|
||||
|
||||
static Json.Obj fetchJson(String method, String url, String token, int timeout) throws IOException {
|
||||
Http.Response r = Http.request(method, url, headers(token), "POST".equals(method) ? new byte[0] : null, timeout);
|
||||
String body = r.readString();
|
||||
if (r.code == 401) throw new PlexException(401, "Not authorized");
|
||||
if (r.code < 200 || r.code >= 300) throw new PlexException(r.code, "HTTP " + r.code);
|
||||
if (body.trim().length() == 0) return new Json.Obj();
|
||||
Object o;
|
||||
try { o = Json.parse(body); } catch (IllegalArgumentException e) { throw new IOException("Bad response from server"); }
|
||||
if (o instanceof Json.Obj) return (Json.Obj) o;
|
||||
Json.Obj wrap = new Json.Obj();
|
||||
wrap.put("_array", o);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
public static final class PlexException extends IOException {
|
||||
public final int code;
|
||||
public PlexException(int code, String msg) { super(msg); this.code = code; }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ PIN sign-in
|
||||
|
||||
public static final class Pin {
|
||||
public long id;
|
||||
public String code;
|
||||
}
|
||||
|
||||
/** Creates a 4-character PIN the user enters at plex.tv/link. */
|
||||
public static Pin createPin() throws IOException {
|
||||
Json.Obj o = fetchJson("POST", TV + "/api/v2/pins?strong=false", null, 20000);
|
||||
Pin p = new Pin();
|
||||
p.id = o.optLong("id", 0);
|
||||
p.code = o.optString("code");
|
||||
if (p.id == 0 || p.code.length() == 0) throw new IOException("plex.tv returned no PIN");
|
||||
return p;
|
||||
}
|
||||
|
||||
/** @return the account token once the PIN has been linked, otherwise null. */
|
||||
public static String checkPin(long id) throws IOException {
|
||||
Json.Obj o = fetchJson("GET", TV + "/api/v2/pins/" + id, null, 20000);
|
||||
String t = o.optString("authToken", "");
|
||||
return t.length() > 0 && !"null".equals(t) ? t : null;
|
||||
}
|
||||
|
||||
public static String username(String accountToken) throws IOException {
|
||||
Json.Obj o = fetchJson("GET", TV + "/api/v2/user", accountToken, 20000);
|
||||
String u = o.optString("username", "");
|
||||
return u.length() > 0 ? u : o.optString("title", "");
|
||||
}
|
||||
|
||||
/** All media servers this account can reach, with every advertised connection. */
|
||||
public static List<Server> servers(String accountToken) throws IOException {
|
||||
Json.Obj o = fetchJson("GET", TV + "/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=0", accountToken, 25000);
|
||||
Object arr = o.get("_array");
|
||||
List<Server> out = new ArrayList<Server>();
|
||||
if (!(arr instanceof Json.Arr)) return out;
|
||||
Json.Arr a = (Json.Arr) arr;
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
Json.Obj r = a.optObj(i);
|
||||
if (r == null) continue;
|
||||
if (r.optString("provides").indexOf("server") < 0) continue;
|
||||
Server s = new Server();
|
||||
s.name = r.optString("name");
|
||||
s.machineId = r.optString("clientIdentifier");
|
||||
s.token = r.optString("accessToken", "");
|
||||
if (s.token.length() == 0) s.token = accountToken;
|
||||
s.owned = r.optBool("owned");
|
||||
s.presence = r.optBool("presence");
|
||||
Json.Arr conns = r.optArr("connections");
|
||||
if (conns != null) {
|
||||
for (int k = 0; k < conns.size(); k++) {
|
||||
Json.Obj c = conns.optObj(k);
|
||||
if (c == null || c.optBool("IPv6")) continue;
|
||||
String uri = c.optString("uri");
|
||||
boolean local = c.optBool("local");
|
||||
boolean relay = c.optBool("relay");
|
||||
String proto = c.optString("protocol");
|
||||
if (relay) {
|
||||
s.add(uri, Server.RELAY);
|
||||
} else if (local) {
|
||||
s.add(uri, Server.LOCAL);
|
||||
// Plain http to the LAN address too: works when the server allows insecure
|
||||
// local connections and some routers block plex.direct DNS (rebind protection).
|
||||
String addr = c.optString("address");
|
||||
int port = c.optInt("port", 32400);
|
||||
if (addr.length() > 0) s.add("http://" + addr + ":" + port, Server.LOCAL_HTTP);
|
||||
} else {
|
||||
s.add(uri, "http".equals(proto) ? Server.REMOTE_HTTP : Server.REMOTE);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.sortConnections();
|
||||
out.add(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.minamp.plex;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import com.minamp.net.Http;
|
||||
import com.minamp.util.Json;
|
||||
|
||||
/**
|
||||
* A Plex Media Server and the ways to reach it. Picks the best working connection
|
||||
* (LAN first, then the public address, then Plex Relay) and re-picks when the network changes.
|
||||
*/
|
||||
public final class Server {
|
||||
|
||||
// Connection kinds, in order of preference.
|
||||
public static final int LOCAL = 0, LOCAL_HTTP = 1, MANUAL = 2, REMOTE = 3, REMOTE_HTTP = 4, RELAY = 5;
|
||||
|
||||
public static final class Conn {
|
||||
public final String uri;
|
||||
public final int kind;
|
||||
Conn(String uri, int kind) { this.uri = uri; this.kind = kind; }
|
||||
}
|
||||
|
||||
public String name = "";
|
||||
public String machineId = "";
|
||||
public String token = "";
|
||||
public boolean owned;
|
||||
public boolean presence;
|
||||
public final List<Conn> connections = new ArrayList<Conn>();
|
||||
|
||||
private volatile Conn active;
|
||||
|
||||
public interface Listener { void onConnectionChanged(Server s); }
|
||||
public static Listener listener;
|
||||
|
||||
public void add(String uri, int kind) {
|
||||
if (uri == null || uri.length() == 0) return;
|
||||
while (uri.endsWith("/")) uri = uri.substring(0, uri.length() - 1);
|
||||
for (int i = 0; i < connections.size(); i++) if (connections.get(i).uri.equals(uri)) return;
|
||||
connections.add(new Conn(uri, kind));
|
||||
}
|
||||
|
||||
public void sortConnections() {
|
||||
Collections.sort(connections, new Comparator<Conn>() {
|
||||
public int compare(Conn a, Conn b) { return a.kind - b.kind; }
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalises what a user types ("192.168.1.5", "myhost:32400", "https://x") into a base URL. */
|
||||
public static String normalizeManual(String input) {
|
||||
String s = input.trim();
|
||||
while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
|
||||
String lower = s.toLowerCase();
|
||||
if (lower.startsWith("http://") || lower.startsWith("https://")) return s; // explicit: keep as typed
|
||||
// bare host or IP: assume plain HTTP on the default Plex port
|
||||
if (s.indexOf(':') < 0 && s.indexOf('/') < 0) s = s + ":32400";
|
||||
return "http://" + s;
|
||||
}
|
||||
|
||||
public Conn active() { return active; }
|
||||
|
||||
public boolean isLocal() {
|
||||
Conn c = active;
|
||||
return c != null && (c.kind == LOCAL || c.kind == LOCAL_HTTP);
|
||||
}
|
||||
|
||||
public boolean isRelay() {
|
||||
Conn c = active;
|
||||
return c != null && c.kind == RELAY;
|
||||
}
|
||||
|
||||
public static String kindLabel(int kind) {
|
||||
switch (kind) {
|
||||
case LOCAL: case LOCAL_HTTP: return "Home network";
|
||||
case MANUAL: return "Direct";
|
||||
case REMOTE: case REMOTE_HTTP: return "Remote";
|
||||
case RELAY: return "Plex Relay";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
public void invalidate() {
|
||||
active = null;
|
||||
Http.evictAll();
|
||||
}
|
||||
|
||||
public String base() throws IOException {
|
||||
Conn c = active;
|
||||
if (c == null) c = pick();
|
||||
return c.uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes every connection in parallel and keeps the most preferred one that answers.
|
||||
* Returns as soon as the best remaining candidate has answered.
|
||||
*/
|
||||
public synchronized Conn pick() throws IOException {
|
||||
if (active != null) return active;
|
||||
if (connections.isEmpty()) throw new IOException("No connections for " + name);
|
||||
final int n = connections.size();
|
||||
final int[] state = new int[n]; // 0 pending, 1 ok, 2 failed
|
||||
final String[] errors = new String[n];
|
||||
final Object lock = new Object();
|
||||
for (int i = 0; i < n; i++) {
|
||||
final int idx = i;
|
||||
final Conn c = connections.get(i);
|
||||
Thread t = new Thread("probe-" + i) {
|
||||
public void run() {
|
||||
boolean ok = false;
|
||||
try {
|
||||
// Give LAN addresses a short timeout, remote/relay a longer one.
|
||||
int timeout = c.kind <= LOCAL_HTTP ? 3500 : 9000;
|
||||
Json.Obj o = Plex.fetchJson("GET", c.uri + "/identity", token, timeout);
|
||||
Json.Obj mc = o.optObj("MediaContainer");
|
||||
String id = mc == null ? "" : mc.optString("machineIdentifier");
|
||||
ok = machineId.length() == 0 || machineId.equals(id);
|
||||
if (!ok) errors[idx] = "different server answered";
|
||||
} catch (Exception e) {
|
||||
errors[idx] = String.valueOf(e.getMessage());
|
||||
}
|
||||
synchronized (lock) {
|
||||
state[idx] = ok ? 1 : 2;
|
||||
lock.notifyAll();
|
||||
}
|
||||
}
|
||||
};
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
long deadline = System.currentTimeMillis() + 12000;
|
||||
synchronized (lock) {
|
||||
while (true) {
|
||||
// the first non-failed candidate in preference order decides
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (state[i] == 2) continue;
|
||||
if (state[i] == 1) {
|
||||
active = connections.get(i);
|
||||
Listener l = listener;
|
||||
if (l != null) l.onConnectionChanged(this);
|
||||
return active;
|
||||
}
|
||||
break; // still waiting on a better candidate
|
||||
}
|
||||
boolean allFailed = true;
|
||||
for (int i = 0; i < n; i++) if (state[i] != 2) allFailed = false;
|
||||
long left = deadline - System.currentTimeMillis();
|
||||
if (allFailed || left <= 0) break;
|
||||
try { lock.wait(left); } catch (InterruptedException e) { break; }
|
||||
}
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("Can't reach " + (name.length() > 0 ? name : "server"));
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (errors[i] != null) { sb.append("\n").append(kindLabel(connections.get(i).kind)).append(": ").append(errors[i]); break; }
|
||||
}
|
||||
throw new IOException(sb.toString());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ requests
|
||||
|
||||
/** GET a library path; on a network error re-picks the connection once and retries. */
|
||||
public Json.Obj get(String path) throws IOException {
|
||||
try {
|
||||
return Plex.fetchJson("GET", base() + path, token, 20000).optObj("MediaContainer");
|
||||
} catch (Plex.PlexException e) {
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
invalidate();
|
||||
return Plex.fetchJson("GET", base() + path, token, 20000).optObj("MediaContainer");
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget GET (timeline, scrobble); never re-picks the connection. */
|
||||
public void fetchQuiet(String path) throws IOException {
|
||||
Conn c = active;
|
||||
if (c == null) return;
|
||||
Http.Response r = Http.get(c.uri + path, Plex.headers(token), 10000);
|
||||
r.readAll();
|
||||
}
|
||||
|
||||
public void put(String path) throws IOException {
|
||||
Plex.fetchJson("PUT", base() + path, token, 15000);
|
||||
}
|
||||
|
||||
public void post(String path) throws IOException {
|
||||
Plex.fetchJson("POST", base() + path, token, 15000);
|
||||
}
|
||||
|
||||
public List<Item> items(String path) throws IOException {
|
||||
return Item.listFrom(get(path));
|
||||
}
|
||||
|
||||
/** Absolute URL with the token in the query string (for the stream proxy and images). */
|
||||
public String url(String path) throws IOException {
|
||||
return base() + path + (path.indexOf('?') >= 0 ? "&" : "?") + "X-Plex-Token=" + Plex.enc(token);
|
||||
}
|
||||
|
||||
public String imageUrl(String thumb, int size) throws IOException {
|
||||
if (thumb == null || thumb.length() == 0) return null;
|
||||
return url("/photo/:/transcode?width=" + size + "&height=" + size + "&minSize=1&upscale=1&url=" + Plex.enc(thumb));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ libraries
|
||||
|
||||
public static final class Section {
|
||||
public String key, title;
|
||||
public String toString() { return title; }
|
||||
}
|
||||
|
||||
public List<Section> musicSections() throws IOException {
|
||||
Json.Obj mc = get("/library/sections");
|
||||
List<Section> out = new ArrayList<Section>();
|
||||
Json.Arr dirs = mc == null ? null : mc.optArr("Directory");
|
||||
if (dirs == null) return out;
|
||||
for (int i = 0; i < dirs.size(); i++) {
|
||||
Json.Obj d = dirs.optObj(i);
|
||||
if (d == null || !"artist".equals(d.optString("type"))) continue;
|
||||
Section s = new Section();
|
||||
s.key = d.optString("key");
|
||||
s.title = d.optString("title");
|
||||
out.add(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ persistence
|
||||
|
||||
public Json.Obj toJson() {
|
||||
Json.Obj o = new Json.Obj();
|
||||
o.put("name", name).put("id", machineId).put("token", token).put("owned", Boolean.valueOf(owned));
|
||||
Json.Arr a = new Json.Arr();
|
||||
for (int i = 0; i < connections.size(); i++) {
|
||||
Conn c = connections.get(i);
|
||||
a.add(new Json.Obj().put("uri", c.uri).put("kind", Long.valueOf(c.kind)));
|
||||
}
|
||||
o.put("conns", a);
|
||||
return o;
|
||||
}
|
||||
|
||||
public static Server fromJson(Json.Obj o) {
|
||||
Server s = new Server();
|
||||
s.name = o.optString("name");
|
||||
s.machineId = o.optString("id");
|
||||
s.token = o.optString("token");
|
||||
s.owned = o.optBool("owned");
|
||||
Json.Arr a = o.optArr("conns");
|
||||
if (a != null) {
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
Json.Obj c = a.optObj(i);
|
||||
if (c != null) s.add(c.optString("uri"), c.optInt("kind", MANUAL));
|
||||
}
|
||||
}
|
||||
s.sortConnections();
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package com.minamp.util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Tiny JSON reader/writer. Plex responses are parsed into {@link Obj}/{@link Arr}, which offer
|
||||
* forgiving opt* accessors (Plex is loose about numbers vs strings).
|
||||
*/
|
||||
public final class Json {
|
||||
|
||||
private Json() {}
|
||||
|
||||
public static final class Obj {
|
||||
public final Map<String, Object> map;
|
||||
public Obj() { map = new HashMap<String, Object>(); }
|
||||
public Obj(Map<String, Object> m) { map = m; }
|
||||
|
||||
public Object get(String k) { return map.get(k); }
|
||||
public boolean has(String k) { return map.get(k) != null; }
|
||||
|
||||
public String optString(String k) { return optString(k, ""); }
|
||||
public String optString(String k, String def) {
|
||||
Object v = map.get(k);
|
||||
if (v == null) return def;
|
||||
if (v instanceof Double) {
|
||||
double d = ((Double) v).doubleValue();
|
||||
if (d == Math.floor(d) && Math.abs(d) < 1e15) return Long.toString((long) d);
|
||||
}
|
||||
return v.toString();
|
||||
}
|
||||
public long optLong(String k, long def) {
|
||||
Object v = map.get(k);
|
||||
if (v instanceof Number) return ((Number) v).longValue();
|
||||
if (v instanceof String) {
|
||||
try { return (long) Double.parseDouble((String) v); } catch (NumberFormatException e) { return def; }
|
||||
}
|
||||
if (v instanceof Boolean) return ((Boolean) v).booleanValue() ? 1 : 0;
|
||||
return def;
|
||||
}
|
||||
public int optInt(String k, int def) { return (int) optLong(k, def); }
|
||||
public double optDouble(String k, double def) {
|
||||
Object v = map.get(k);
|
||||
if (v instanceof Number) return ((Number) v).doubleValue();
|
||||
if (v instanceof String) {
|
||||
try { return Double.parseDouble((String) v); } catch (NumberFormatException e) { return def; }
|
||||
}
|
||||
return def;
|
||||
}
|
||||
public boolean optBool(String k) {
|
||||
Object v = map.get(k);
|
||||
if (v instanceof Boolean) return ((Boolean) v).booleanValue();
|
||||
if (v instanceof Number) return ((Number) v).intValue() != 0;
|
||||
if (v instanceof String) return "1".equals(v) || "true".equals(v);
|
||||
return false;
|
||||
}
|
||||
public Obj optObj(String k) {
|
||||
Object v = map.get(k);
|
||||
return v instanceof Obj ? (Obj) v : null;
|
||||
}
|
||||
public Arr optArr(String k) {
|
||||
Object v = map.get(k);
|
||||
return v instanceof Arr ? (Arr) v : null;
|
||||
}
|
||||
public Obj put(String k, Object v) { map.put(k, v); return this; }
|
||||
|
||||
@Override public String toString() { StringBuilder sb = new StringBuilder(); write(sb, this); return sb.toString(); }
|
||||
}
|
||||
|
||||
public static final class Arr {
|
||||
public final List<Object> list;
|
||||
public Arr() { list = new ArrayList<Object>(); }
|
||||
public int size() { return list.size(); }
|
||||
public Object get(int i) { return list.get(i); }
|
||||
public Obj optObj(int i) {
|
||||
Object v = i < list.size() ? list.get(i) : null;
|
||||
return v instanceof Obj ? (Obj) v : null;
|
||||
}
|
||||
public Arr add(Object v) { list.add(v); return this; }
|
||||
@Override public String toString() { StringBuilder sb = new StringBuilder(); write(sb, this); return sb.toString(); }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ parsing
|
||||
|
||||
public static Object parse(String s) {
|
||||
P p = new P(s);
|
||||
p.ws();
|
||||
Object v = p.value();
|
||||
p.ws();
|
||||
if (p.i < s.length()) throw new IllegalArgumentException("Trailing data at " + p.i);
|
||||
return v;
|
||||
}
|
||||
|
||||
public static Obj parseObj(String s) {
|
||||
Object o = parse(s);
|
||||
if (!(o instanceof Obj)) throw new IllegalArgumentException("Expected JSON object");
|
||||
return (Obj) o;
|
||||
}
|
||||
|
||||
private static final class P {
|
||||
final String s; int i;
|
||||
P(String s) { this.s = s; }
|
||||
|
||||
void ws() {
|
||||
while (i < s.length()) {
|
||||
char c = s.charAt(i);
|
||||
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') i++; else break;
|
||||
}
|
||||
}
|
||||
|
||||
IllegalArgumentException err(String m) { return new IllegalArgumentException(m + " at " + i); }
|
||||
|
||||
Object value() {
|
||||
if (i >= s.length()) throw err("Unexpected end");
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '{': return obj();
|
||||
case '[': return arr();
|
||||
case '"': return str();
|
||||
case 't': expect("true"); return Boolean.TRUE;
|
||||
case 'f': expect("false"); return Boolean.FALSE;
|
||||
case 'n': expect("null"); return null;
|
||||
default: return num();
|
||||
}
|
||||
}
|
||||
|
||||
void expect(String w) {
|
||||
if (!s.startsWith(w, i)) throw err("Expected " + w);
|
||||
i += w.length();
|
||||
}
|
||||
|
||||
Obj obj() {
|
||||
Obj o = new Obj();
|
||||
i++; ws();
|
||||
if (i < s.length() && s.charAt(i) == '}') { i++; return o; }
|
||||
while (true) {
|
||||
ws();
|
||||
if (i >= s.length() || s.charAt(i) != '"') throw err("Expected key");
|
||||
String k = str();
|
||||
ws();
|
||||
if (i >= s.length() || s.charAt(i) != ':') throw err("Expected :");
|
||||
i++; ws();
|
||||
o.map.put(k, value());
|
||||
ws();
|
||||
if (i >= s.length()) throw err("Unterminated object");
|
||||
char c = s.charAt(i++);
|
||||
if (c == '}') return o;
|
||||
if (c != ',') throw err("Expected , or }");
|
||||
}
|
||||
}
|
||||
|
||||
Arr arr() {
|
||||
Arr a = new Arr();
|
||||
i++; ws();
|
||||
if (i < s.length() && s.charAt(i) == ']') { i++; return a; }
|
||||
while (true) {
|
||||
ws();
|
||||
a.list.add(value());
|
||||
ws();
|
||||
if (i >= s.length()) throw err("Unterminated array");
|
||||
char c = s.charAt(i++);
|
||||
if (c == ']') return a;
|
||||
if (c != ',') throw err("Expected , or ]");
|
||||
}
|
||||
}
|
||||
|
||||
String str() {
|
||||
i++; // opening quote
|
||||
StringBuilder sb = null;
|
||||
int start = i;
|
||||
while (i < s.length()) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '"') {
|
||||
String r = sb == null ? s.substring(start, i) : sb.append(s, start, i).toString();
|
||||
i++;
|
||||
return r;
|
||||
}
|
||||
if (c == '\\') {
|
||||
if (sb == null) sb = new StringBuilder();
|
||||
sb.append(s, start, i);
|
||||
i++;
|
||||
if (i >= s.length()) break;
|
||||
char e = s.charAt(i++);
|
||||
switch (e) {
|
||||
case 'n': sb.append('\n'); break;
|
||||
case 't': sb.append('\t'); break;
|
||||
case 'r': sb.append('\r'); break;
|
||||
case 'b': sb.append('\b'); break;
|
||||
case 'f': sb.append('\f'); break;
|
||||
case 'u':
|
||||
if (i + 4 > s.length()) throw err("Bad escape");
|
||||
sb.append((char) Integer.parseInt(s.substring(i, i + 4), 16));
|
||||
i += 4;
|
||||
break;
|
||||
default: sb.append(e);
|
||||
}
|
||||
start = i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
throw err("Unterminated string");
|
||||
}
|
||||
|
||||
Object num() {
|
||||
int start = i;
|
||||
while (i < s.length()) {
|
||||
char c = s.charAt(i);
|
||||
if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E') i++;
|
||||
else break;
|
||||
}
|
||||
if (start == i) throw err("Unexpected character");
|
||||
String n = s.substring(start, i);
|
||||
try {
|
||||
if (n.indexOf('.') < 0 && n.indexOf('e') < 0 && n.indexOf('E') < 0 && n.length() < 18) {
|
||||
return Long.valueOf(n);
|
||||
}
|
||||
return Double.valueOf(n);
|
||||
} catch (NumberFormatException e) {
|
||||
throw err("Bad number");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ writing
|
||||
|
||||
public static void write(StringBuilder sb, Object v) {
|
||||
if (v == null) { sb.append("null"); return; }
|
||||
if (v instanceof String) { quote(sb, (String) v); return; }
|
||||
if (v instanceof Number || v instanceof Boolean) { sb.append(v.toString()); return; }
|
||||
if (v instanceof Obj) {
|
||||
sb.append('{');
|
||||
boolean first = true;
|
||||
for (Iterator<Map.Entry<String, Object>> it = ((Obj) v).map.entrySet().iterator(); it.hasNext();) {
|
||||
Map.Entry<String, Object> e = it.next();
|
||||
if (e.getValue() == null) continue;
|
||||
if (!first) sb.append(',');
|
||||
first = false;
|
||||
quote(sb, e.getKey());
|
||||
sb.append(':');
|
||||
write(sb, e.getValue());
|
||||
}
|
||||
sb.append('}');
|
||||
return;
|
||||
}
|
||||
if (v instanceof Arr) {
|
||||
sb.append('[');
|
||||
List<Object> l = ((Arr) v).list;
|
||||
for (int i = 0; i < l.size(); i++) {
|
||||
if (i > 0) sb.append(',');
|
||||
write(sb, l.get(i));
|
||||
}
|
||||
sb.append(']');
|
||||
return;
|
||||
}
|
||||
quote(sb, v.toString());
|
||||
}
|
||||
|
||||
private static void quote(StringBuilder sb, String s) {
|
||||
sb.append('"');
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
String h = Integer.toHexString(c);
|
||||
sb.append("\\u");
|
||||
for (int k = h.length(); k < 4; k++) sb.append('0');
|
||||
sb.append(h);
|
||||
} else sb.append(c);
|
||||
}
|
||||
}
|
||||
sb.append('"');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user