package com.minamp; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; 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 = com.minamp.net.Threads.pool("task", 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 { public abstract T work() throws Exception; public void done(T result) {} public void fail(Exception e) {} } protected void run(final Task 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 Throwable e) { // Errors (out of memory, stack overflow, a class the old VM can't load) must not kill the app if (e instanceof OutOfMemoryError) app.images.clearMemory(); App.logError("task", e); final Exception wrapped = new Exception(com.minamp.net.Threads.describe(e)); app.handler.post(new Runnable() { public void run() { if (!destroyed) t.fail(wrapped); } }); } } }); } protected void toast(String s) { Toast.makeText(this, s, s != null && s.length() > 40 ? Toast.LENGTH_LONG : 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(); } /** If the last run hit an error, show it so it can be reported (also saved as Minamp-error.txt on the SD card). */ protected void showCrashReportIfAny() { final String report = app.takeCrashReport(); if (report == null) return; android.widget.ScrollView sv = new android.widget.ScrollView(this); TextView tv = new TextView(this); tv.setText(report); tv.setTextSize(10); tv.setTextColor(0xFFDDDDDD); int p = Ui.dp(this, 8); tv.setPadding(p, p, p, p); sv.addView(tv); new AlertDialog.Builder(this) .setTitle("Minamp hit an error last time") .setView(sv) .setPositiveButton("OK", null) .show(); } @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 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 q = contextTracks; int idx = trackIndex; if (q == null || q.isEmpty()) { q = new ArrayList(); q.add(it); idx = 0; } playList(q, idx, false, sourceTitle); break; } } protected void playList(List 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>() { public List work() throws Exception { return lib.tracksOf(it); } public void done(List 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> fetch) { toast("Starting " + title + "…"); run(new Task>() { public List work() throws Exception { return fetch.work(); } public void done(List 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 labels = new ArrayList(); final List actions = new ArrayList(); 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 one = new ArrayList(); 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() { 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, "Connection log & streaming test", "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() { 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: showDiagnostics(); break; case 7: confirmSignOut(); break; } } }).show(); } protected void onConnectionChanged() {} // ------------------------------------------------------------------ diagnostics /** Connection log with "test streaming" and "save to SD card", for reporting problems. */ protected void showDiagnostics() { final android.widget.ScrollView sv = new android.widget.ScrollView(this); final TextView tv = new TextView(this); tv.setTextSize(9); tv.setTextColor(0xFFDDDDDD); tv.setTypeface(android.graphics.Typeface.MONOSPACE); int p = Ui.dp(this, 6); tv.setPadding(p, p, p, p); String log = com.minamp.net.DiagLog.dump(); tv.setText(log.length() > 0 ? log : "Nothing logged yet."); sv.addView(tv); AlertDialog.Builder b = new AlertDialog.Builder(this) .setTitle("Connection log") .setView(sv) .setNegativeButton("Save to SD card", new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { saveDiagnostics(); } }) .setPositiveButton("Close", null); if (app.library != null) { b.setNeutralButton("Test streaming", new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { testStreaming(); } }); } AlertDialog dlg = b.create(); dlg.show(); sv.post(new Runnable() { public void run() { sv.fullScroll(View.FOCUS_DOWN); } }); } private void saveDiagnostics() { try { if (!android.os.Environment.MEDIA_MOUNTED.equals(android.os.Environment.getExternalStorageState())) { toast("No SD card available"); return; } java.io.File f = new java.io.File(android.os.Environment.getExternalStorageDirectory(), "Minamp-log.txt"); java.io.FileOutputStream o = new java.io.FileOutputStream(f); String head = "Minamp " + com.minamp.plex.Plex.version + " on " + android.os.Build.MODEL + ", Android " + android.os.Build.VERSION.RELEASE + "\n"; o.write((head + com.minamp.net.DiagLog.dump()).getBytes("UTF-8")); o.close(); toast("Saved as Minamp-log.txt on the SD card"); } catch (Exception e) { toast("Couldn't save: " + msg(e)); } } /** Asks the server about every transcode variant for one track, and logs exactly what it says. */ private void testStreaming() { final Library lib = lib(); final Item current = PlayQueue.get().current(); toast("Testing\u2026 (about 30 s)"); run(new Task() { public Object work() throws Exception { com.minamp.net.DiagLog.log("=== streaming test ==="); Item t = current; if (t == null) { List r = lib.libraryRadio(); if (r.isEmpty()) throw new Exception("no tracks in the library"); t = r.get(0); } lib.ensureMedia(t); com.minamp.net.DiagLog.log("track: " + t.title + " [" + t.codec + "/" + t.container + " " + t.bitrate + " kbps] via " + (lib.server.active() == null ? "?" : Server.kindLabel(lib.server.active().kind))); int kbps = app.currentMaxKbps(); if (kbps <= 0) kbps = 320; for (int v = 0; v < Library.VARIANTS; v++) { com.minamp.net.DiagLog.log("variant " + v + " (" + Library.VARIANT_NAMES[v] + ") decision: " + lib.decision(t, kbps, v)); String session = "minamp-test-" + System.currentTimeMillis() + "-" + v; String url = lib.transcodeUrl(t, kbps, 0, session, v); com.minamp.net.Http.Response r = null; try { r = com.minamp.net.Http.get(url, null, 30000); if (r.code < 400) { byte[] buf = new byte[4096]; int n = r.body.read(buf); boolean mp3 = n > 3 && ((buf[0] == 'I' && buf[1] == 'D' && buf[2] == '3') || ((buf[0] & 0xFF) == 0xFF && (buf[1] & 0xE0) == 0xE0)); com.minamp.net.DiagLog.log("variant " + v + " start: " + r.code + ", " + r.header("content-type") + ", first bytes " + (mp3 ? "look like MP3" : "are NOT MP3")); } else { com.minamp.net.DiagLog.log("variant " + v + " start: " + r.code + " " + com.minamp.plex.Plex.snippet(r.readString())); } } catch (Exception e) { com.minamp.net.DiagLog.log("variant " + v + " start failed: " + com.minamp.net.Threads.describe(e)); } finally { com.minamp.net.Http.abort(r); lib.stopTranscode(session); } } com.minamp.net.DiagLog.log("=== end of test ==="); return null; } public void done(Object o) { showDiagnostics(); } public void fail(Exception e) { com.minamp.net.DiagLog.log("streaming test failed: " + msg(e)); showDiagnostics(); } }); } 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(); } }