Import minamp 1.0 source

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-14 19:38:37 +02:00
commit 0df8124629
194 files changed
+7319

No files matched your search

+183
View File
@@ -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();
}
}
}
+448
View File
@@ -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();
}
}
+163
View File
@@ -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();
}
}
+206
View File
@@ -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; }
}
+428
View File
@@ -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);
}
}
+199
View File
@@ -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; }
}
}
+153
View File
@@ -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;
}
}
+53
View File
@@ -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;
}
}
}
+229
View File
@@ -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();
}
}
+249
View File
@@ -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();
}
}
+669
View File
@@ -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;
}
}
+95
View File
@@ -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();
}
}
+116
View File
@@ -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); } });
}
});
}
}
+290
View File
@@ -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();
}
}
+22
View File
@@ -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);
}
}
+71
View File
@@ -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);
}
}
+226
View File
@@ -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);
}
}
}
+542
View File
@@ -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) {}
}
}
}
+207
View File
@@ -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";
}
}
+166
View File
@@ -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)); }
}
+225
View File
@@ -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&sectionID=" + section;
}
public String searchPath(String query) {
return "/hubs/search?query=" + Plex.enc(query) + "&sectionId=" + 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);
}
}
+144
View File
@@ -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;
}
}
+258
View File
@@ -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;
}
}
+283
View File
@@ -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('"');
}
}