Files
plexamp-lite/src/com/minamp/BaseActivity.java
T
xockoandClaude Opus 5.5 a4b875ef34 Minamp 1.2
- Transcoding: send client identities Plex has a profile for (unknown ones get
  400 "unable to find a matching profile"), with a fresh session id per variant
- Stream proxy: fetch transcodes once into a temp file and serve every player
  request from it, so reconnects no longer restart the transcode (503s, tracks
  cut short); original files still pass Range requests through
- Sign-in: remember the pending link code so it survives Minamp being closed
  while the code is entered elsewhere; add "I've entered it - check now"
- Diagnostics: in-app connection log (DiagLog) with a streaming test and save
  to SD card; tokens are never logged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:38:17 +02:00

574 lines
25 KiB
Java

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<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 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<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,
"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<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: 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<Object>() {
public Object work() throws Exception {
com.minamp.net.DiagLog.log("=== streaming test ===");
Item t = current;
if (t == null) {
List<Item> 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();
}
}