Backport from the iOS version: offline downloads, home-first reconnect, stall warning, Close

- Downloads for offline listening: long-press a track, album, artist or
  playlist -> Download; Menu -> Downloads lists them (progress, space used/free,
  play/shuffle, retry, remove). Saved on the SD card (Android/data/com.minamp/
  files/downloads). MP3 originals kept as they are unless above a lower
  download quality; the rest transcoded to MP3 (320 kbps by default). One at a
  time, Range resume for originals, retried when the server is back, Wi-Fi lock.
- Downloaded tracks play from the SD card, offline too; plays made offline
  are reported to Plex when a connection works again.
- Server.pick: when last time's connection was remote, give the plain home
  addresses 2 s first (a hairpinned remote address kept us encrypted and
  capped at home). test/PreferHomeTest.java.
- Playback "playing" but not moving for 12 s: say so instead of staying silent.
- Menu -> Close Minamp: stop the music and close every screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XoséandClaude Opus 5.5 committed 2026-10-17 16:03:46 +02:00
1 parent f7f49ecfbb
commit f9050bbac0
15 files changed
+933 -43

No files matched your search

+39
View File
@@ -22,6 +22,7 @@ import android.os.Handler;
import com.minamp.net.CertVerifier;
import com.minamp.net.Http;
import com.minamp.plex.Item;
import com.minamp.plex.Library;
import com.minamp.plex.Plex;
import com.minamp.plex.Server;
@@ -83,6 +84,8 @@ public class App extends Application {
handler.post(new Runnable() {
public void run() {
if (server == s) prefs.edit().putString("server", s.toJson().toString()).commit();
flushPendingScrobbles();
Downloads.get().kick(); // downloads waiting for the server can carry on
}
});
}
@@ -94,6 +97,7 @@ public class App extends Application {
@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();
Downloads.get().kick();
}
}, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
}
@@ -232,6 +236,41 @@ public class App extends Application {
library = new Library(server, key);
}
// ------------------------------------------------------------------ offline plays
/** A play of a downloaded track while the server was unreachable: reported later. */
public synchronized void addPendingScrobble(String ratingKey) {
String cur = prefs.getString("pendingScrobbles", "");
String[] parts = cur.length() == 0 ? new String[0] : cur.split(",");
StringBuilder sb = new StringBuilder();
for (int i = Math.max(0, parts.length - 499); i < parts.length; i++) sb.append(parts[i]).append(',');
sb.append(ratingKey);
prefs.edit().putString("pendingScrobbles", sb.toString()).commit();
com.minamp.net.DiagLog.log("offline: play of " + ratingKey + " will be reported later");
}
/** Sends plays kept while offline (called when a connection has just been found). */
void flushPendingScrobbles() {
final String cur;
synchronized (this) {
cur = prefs.getString("pendingScrobbles", "");
if (cur.length() == 0 || library == null) return;
prefs.edit().remove("pendingScrobbles").commit();
}
final Library lib = library;
com.minamp.net.Threads.start(new Runnable() {
public void run() {
String[] keys = cur.split(",");
for (int i = 0; i < keys.length; i++) {
Item t = new Item();
t.ratingKey = keys[i];
lib.scrobble(t);
}
com.minamp.net.DiagLog.log("reported " + keys.length + " offline play(s)");
}
}, "offline-plays");
}
public boolean isConfigured() { return server != null && library != null; }
public String sectionTitle() { return prefs.getString("sectionTitle", "Music"); }
+80 -9
View File
@@ -304,12 +304,15 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
if (it.albumKey.length() > 0) { labels.add(str(R.string.go_to_album)); actions.add(5); }
if (it.artistKey.length() > 0) { labels.add(str(R.string.go_to_artist)); actions.add(6); }
labels.add(str(it.loved() ? R.string.unlove : R.string.love)); actions.add(7);
if (Downloads.get().entry(it) != null) { labels.add(str(R.string.remove_download)); actions.add(9); }
else { labels.add(str(R.string.download)); actions.add(8); }
} else if (it.isContainer()) {
labels.add(str(R.string.play)); actions.add(0);
labels.add(str(it.type == Item.ARTIST ? R.string.artist_radio_shuffle : R.string.shuffle)); actions.add(3);
labels.add(str(R.string.play_next)); actions.add(1);
labels.add(str(R.string.add_to_queue)); actions.add(2);
if (it.type == Item.ALBUM && it.artistKey.length() > 0) { labels.add(str(R.string.go_to_artist)); actions.add(6); }
labels.add(str(R.string.download)); actions.add(8);
} else return;
new AlertDialog.Builder(this)
.setTitle(it.title)
@@ -339,9 +342,33 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
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;
case 8: download(it); break;
case 9: Downloads.get().remove(it); break;
}
}
/** Queue a track, or every track of an album/artist/playlist, for offline listening. */
protected void download(final Item it) {
if (it.type == Item.TRACK) {
List<Item> one = new ArrayList<Item>();
one.add(it);
int n = Downloads.get().add(one);
toast(n > 0 ? str(R.string.queued_n, n) : str(R.string.already_downloaded));
return;
}
final Library lib = lib();
if (lib == null) return;
toast(str(R.string.downloading_x, it.title));
run(new Task<List<Item>>() {
public List<Item> work() throws Exception { return lib.tracksOf(it); }
public void done(List<Item> tracks) {
int n = Downloads.get().add(tracks);
toast(n > 0 ? str(R.string.queued_n, n) : str(R.string.already_downloaded));
}
public void fail(Exception e) { toast(msg(e)); }
});
}
protected void openKey(String ratingKey, int type, String title) {
Item i = new Item();
i.type = type;
@@ -376,7 +403,9 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
public boolean onCreateOptionsMenu(Menu menu) {
menu.add(0, 1, 0, R.string.now_playing);
menu.add(0, 2, 0, R.string.search);
menu.add(0, 4, 0, R.string.downloads);
menu.add(0, 3, 0, R.string.settings);
menu.add(0, 5, 0, R.string.quit_app);
return true;
}
@@ -386,10 +415,19 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
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;
case 4: startActivity(new Intent(this, DownloadsActivity.class)); return true;
case 5: quitApp(); return true;
}
return super.onOptionsItemSelected(item);
}
/** Stops the music and closes every screen (back to the launcher). */
protected void quitApp() {
PlayerService.send(this, PlayerService.ACTION_STOP);
startActivity(new Intent(this, HomeActivity.class).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP).putExtra("exit", true));
if (!(this instanceof HomeActivity)) finish();
}
protected void showSettings() {
Server s = app.server;
String conn = s == null || s.active() == null ? str(R.string.not_connected_yet) : Server.kindLabel(s.active().kind)
@@ -397,6 +435,8 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
final String[] rows = {
str(R.string.set_quality_home, qualityLabel(app.qualityHome())),
str(R.string.set_quality_away, qualityLabel(app.qualityAway())),
str(R.string.set_dl_quality, dlQualityLabel(Downloads.get().quality())),
str(R.string.set_delete_downloads, Downloads.size(Downloads.get().bytesOnDisk())),
str(R.string.set_download_first, str(app.downloadFirst() ? R.string.on : R.string.off)),
str(R.string.set_secure_home, str(Server.preferSecureAtHome ? R.string.on : R.string.off)),
str(R.string.set_library, app.sectionTitle()),
@@ -414,19 +454,21 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
switch (which) {
case 0: pickQuality("qHome", app.qualityHome(), str(R.string.quality_home_title)); break;
case 1: pickQuality("qAway", app.qualityAway(), str(R.string.quality_away_title)); break;
case 2:
case 2: pickDownloadQuality(); break;
case 3: confirmDeleteDownloads(); break;
case 4:
app.prefs.edit().putBoolean("downloadFirst", !app.downloadFirst()).commit();
toast(str(R.string.download_first_toast, str(app.downloadFirst() ? R.string.on : R.string.off)));
break;
case 3:
case 5:
Server.preferSecureAtHome = !Server.preferSecureAtHome;
app.prefs.edit().putBoolean("secureHome", Server.preferSecureAtHome).commit();
if (app.server != null) app.server.invalidate();
toast(str(Server.preferSecureAtHome ? R.string.https_home_on : R.string.https_home_off));
break;
case 4: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break;
case 5: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break;
case 6:
case 6: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break;
case 7: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break;
case 8:
if (app.server != null) {
app.server.invalidate();
toast(str(R.string.reconnecting));
@@ -438,10 +480,10 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
});
}
break;
case 7: showDiagnostics(); break;
case 8: pickLanguage(); break;
case 9: pickSize(); break;
case 10: confirmSignOut(); break;
case 9: showDiagnostics(); break;
case 10: pickLanguage(); break;
case 11: pickSize(); break;
case 12: confirmSignOut(); break;
}
}
}).show();
@@ -566,6 +608,35 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
});
}
private String dlQualityLabel(int kbps) {
return kbps >= 320 ? str(R.string.dl_quality_original) : kbps + " kbps MP3";
}
private void pickDownloadQuality() {
final int[] values = {320, 256, 192, 128};
final String[] labels = {dlQualityLabel(320), dlQualityLabel(256), dlQualityLabel(192), dlQualityLabel(128)};
int sel = 0;
for (int i = 0; i < values.length; i++) if (values[i] == Downloads.get().quality()) sel = i;
new AlertDialog.Builder(this)
.setTitle(R.string.dl_quality_title)
.setSingleChoiceItems(labels, sel, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int which) {
Downloads.get().setQuality(values[which]);
d.dismiss();
}
}).show();
}
protected void confirmDeleteDownloads() {
new AlertDialog.Builder(this)
.setMessage(R.string.delete_all_confirm)
.setPositiveButton(R.string.delete_all_downloads, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) { Downloads.get().removeAll(); }
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private String qualityLabel(int kbps) {
if (kbps <= 0) return str(R.string.quality_original);
for (int i = 0; i < App.QUALITIES.length; i++) if (App.QUALITIES[i] == kbps) return App.QUALITY_LABELS[i];
+435
View File
@@ -0,0 +1,435 @@
package com.minamp;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import android.content.Context;
import android.net.wifi.WifiManager;
import android.os.Environment;
import android.os.StatFs;
import com.minamp.net.DiagLog;
import com.minamp.net.Http;
import com.minamp.net.Threads;
import com.minamp.plex.Item;
import com.minamp.plex.Library;
import com.minamp.util.Json;
/**
* Tracks saved for offline listening (SD card: Android/data/com.minamp/files/downloads/<ratingKey>.mp3).
* MP3 originals are kept as they are, unless above a lower quality chosen to save space; anything
* else is transcoded to MP3 by the server. One track at a time; interrupted originals resume with
* Range, everything waits and retries when the server is unreachable. Lists are read and changed
* on the main thread only.
*/
public final class Downloads {
public static final int QUEUED = 0, DOWNLOADING = 1, DONE = 2, FAILED = 3;
private static final long MIN_FREE = 100L * 1024 * 1024;
private static final int MAX_TRIES = 5;
public static final class Entry {
public Item item;
public int state;
public long bytes, total = -1;
public String error;
public int tries;
}
public interface Listener { void onDownloadsChanged(); }
private static Downloads instance;
public static synchronized Downloads get() {
if (instance == null) instance = new Downloads(App.get());
return instance;
}
private final App app;
private final List<Entry> list = new ArrayList<Entry>();
private final Map<String, Entry> byKey = new HashMap<String, Entry>();
private final List<Listener> listeners = new ArrayList<Listener>();
private Entry active;
private volatile Http.Response resp;
private volatile boolean cancel;
private long waitUntil;
private long lastNotify;
private WifiManager.WifiLock wifiLock;
private Downloads(App app) {
this.app = app;
load();
app.handler.postDelayed(new Runnable() { public void run() { pump(); } }, 3000);
}
// ------------------------------------------------------------------ storage
/** On the SD card when there is one (the Galaxy Mini's internal storage is tiny). */
private File dir() {
File d = null;
try {
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) d = app.getExternalFilesDir("downloads");
} catch (Exception ignored) {}
if (d == null) d = new File(app.getFilesDir(), "downloads");
if (!d.exists()) d.mkdirs();
return d;
}
private File fileOf(Item t, boolean part) { return new File(dir(), t.ratingKey + (part ? ".mp3.part" : ".mp3")); }
private File indexFile() { return new File(app.getFilesDir(), "downloads.json"); }
private void load() {
try {
File f = indexFile();
if (!f.exists()) return;
Json.Arr a = (Json.Arr) Json.parse(new String(App.readAll(new FileInputStream(f)), "UTF-8"));
for (int i = 0; i < a.size(); i++) {
Json.Obj o = a.optObj(i);
if (o == null || o.optObj("item") == null) continue;
Entry e = new Entry();
e.item = Item.fromJson(o.optObj("item"));
if (e.item.ratingKey.length() == 0 || byKey.containsKey(e.item.ratingKey)) continue;
e.state = o.optInt("state", QUEUED);
if (e.state == DOWNLOADING) e.state = QUEUED;
e.bytes = o.optLong("bytes", 0);
e.total = o.optLong("total", -1);
e.tries = o.optInt("tries", 0);
String err = o.optString("error", "");
e.error = err.length() > 0 ? err : null;
if (e.state == DONE && !fileOf(e.item, false).exists()) continue; // SD card swapped or files deleted
list.add(e);
byKey.put(e.item.ratingKey, e);
}
} catch (Exception e) {
DiagLog.log("downloads index unreadable: " + Threads.describe(e));
}
}
private void save() {
Json.Arr a = new Json.Arr();
for (int i = 0; i < list.size(); i++) {
Entry e = list.get(i);
a.add(new Json.Obj().put("item", e.item.toJson()).put("state", Long.valueOf(e.state))
.put("bytes", Long.valueOf(e.bytes)).put("total", Long.valueOf(e.total))
.put("tries", Long.valueOf(e.tries)).put("error", e.error == null ? "" : e.error));
}
final String data = a.toString();
Threads.start(new Runnable() {
public void run() {
synchronized (Downloads.class) {
try {
File tmp = new File(app.getFilesDir(), "downloads.tmp");
FileOutputStream out = new FileOutputStream(tmp);
out.write(data.getBytes("UTF-8"));
out.close();
tmp.renameTo(indexFile());
} catch (Exception ignored) {}
}
}
}, "save-downloads");
}
// ------------------------------------------------------------------ listeners
public void addListener(Listener l) { if (!listeners.contains(l)) listeners.add(l); }
public void removeListener(Listener l) { listeners.remove(l); }
private void changed(boolean force) {
long now = System.currentTimeMillis();
if (!force && now - lastNotify < 500) return;
lastNotify = now;
for (int i = listeners.size() - 1; i >= 0; i--) if (i < listeners.size()) listeners.get(i).onDownloadsChanged();
}
// ------------------------------------------------------------------ queries
public Entry entry(Item t) { return t == null ? null : byKey.get(t.ratingKey); }
/** The saved file of a finished download, or null. */
public File fileFor(Item t) {
Entry e = entry(t);
return e != null && e.state == DONE ? fileOf(e.item, false) : null;
}
public boolean isDownloaded(Item t) { return fileFor(t) != null; }
public List<Entry> pending() {
List<Entry> out = new ArrayList<Entry>();
for (int i = 0; i < list.size(); i++) if (list.get(i).state != DONE) out.add(list.get(i));
return out;
}
/** Finished downloads, by artist, album and track number. */
public List<Entry> done() {
List<Entry> out = new ArrayList<Entry>();
for (int i = 0; i < list.size(); i++) if (list.get(i).state == DONE) out.add(list.get(i));
Collections.sort(out, new Comparator<Entry>() {
public int compare(Entry x, Entry y) {
int r = x.item.artist.compareToIgnoreCase(y.item.artist);
if (r == 0) r = x.item.album.compareToIgnoreCase(y.item.album);
if (r == 0) r = x.item.index - y.item.index;
return r;
}
});
return out;
}
public List<Item> doneTracks() {
List<Entry> d = done();
List<Item> out = new ArrayList<Item>();
for (int i = 0; i < d.size(); i++) out.add(d.get(i).item);
return out;
}
public long bytesOnDisk() {
long n = 0;
for (int i = 0; i < list.size(); i++) if (list.get(i).state == DONE) n += list.get(i).bytes;
return n;
}
public long freeSpace() {
try {
StatFs s = new StatFs(dir().getPath());
return (long) s.getAvailableBlocks() * s.getBlockSize();
} catch (Exception e) {
return -1;
}
}
public static String size(long b) {
if (b >= 1024L * 1024 * 1024) return String.format(java.util.Locale.US, "%.1f GB", b / (1024.0 * 1024 * 1024));
if (b >= 1024 * 1024) return (b / (1024 * 1024)) + " MB";
return (b / 1024) + " KB";
}
/** 320 (default): MP3 originals as they are, the rest at 320 kbps. Lower: everything above it shrunk. */
public int quality() { return app.prefs.getInt("dlQuality", 320); }
public void setQuality(int kbps) { app.prefs.edit().putInt("dlQuality", kbps).commit(); }
// ------------------------------------------------------------------ changes
/** Queues tracks; returns how many were new (failed ones are retried). */
public int add(List<Item> tracks) {
int added = 0;
for (int i = 0; i < tracks.size(); i++) {
Item t = tracks.get(i);
if (t.type != Item.TRACK || t.ratingKey.length() == 0) continue;
Entry e = byKey.get(t.ratingKey);
if (e != null) {
if (e.state == FAILED) { e.state = QUEUED; e.tries = 0; e.error = null; added++; }
continue;
}
e = new Entry();
e.item = t;
list.add(e);
byKey.put(t.ratingKey, e);
added++;
}
save();
changed(true);
kick();
return added;
}
public void remove(Item t) {
Entry e = entry(t);
if (e == null) return;
if (e == active) {
cancel = true;
Http.abort(resp);
} else {
fileOf(e.item, false).delete();
fileOf(e.item, true).delete();
}
list.remove(e);
byKey.remove(e.item.ratingKey);
save();
changed(true);
}
public void removeAll() {
List<Entry> all = new ArrayList<Entry>(list);
for (int i = 0; i < all.size(); i++) remove(all.get(i).item);
}
public void cancelPending() {
List<Entry> p = pending();
for (int i = 0; i < p.size(); i++) remove(p.get(i).item);
}
public void retry(Item t) {
Entry e = entry(t);
if (e == null || e.state != FAILED) return;
e.state = QUEUED;
e.tries = 0;
e.error = null;
save();
changed(true);
kick();
}
// ------------------------------------------------------------------ worker
/** Network or connection changed: resume waiting downloads. */
public void kick() {
waitUntil = 0;
pump();
}
private void pump() {
if (active != null || System.currentTimeMillis() < waitUntil) return;
Entry next = null;
for (int i = 0; i < list.size(); i++) if (list.get(i).state == QUEUED) { next = list.get(i); break; }
final Library lib = app.library;
if (next == null || lib == null) { releaseWifi(); return; }
if (wifiLock == null) {
wifiLock = ((WifiManager) app.getSystemService(Context.WIFI_SERVICE)).createWifiLock(WifiManager.WIFI_MODE_FULL, "minamp-dl");
wifiLock.setReferenceCounted(false);
}
if (!wifiLock.isHeld()) wifiLock.acquire();
active = next;
active.state = DOWNLOADING;
active.error = null;
cancel = false;
changed(true);
final Entry e = active;
final int quality = quality();
Threads.start(new Runnable() {
public void run() {
final boolean[] permanent = new boolean[1];
String err;
try {
err = fetch(e, lib, quality, permanent);
} catch (Throwable t) {
err = Threads.describe(t);
}
final String error = err;
app.handler.post(new Runnable() { public void run() { finished(e, error, permanent[0]); } });
}
}, "download");
}
private void releaseWifi() {
if (wifiLock != null && wifiLock.isHeld()) wifiLock.release();
}
/** Background thread. Returns null on success, otherwise why not. */
private String fetch(final Entry e, Library lib, int quality, boolean[] permanent) throws IOException {
Item t = e.item;
try {
lib.ensureMedia(t);
} catch (com.minamp.plex.Plex.PlexException pe) {
permanent[0] = pe.code >= 400 && pe.code < 500;
return pe.getMessage();
}
long free = freeSpace();
if (free >= 0 && free < MIN_FREE) { permanent[0] = true; return app.getString(R.string.dl_no_space); }
boolean direct = Library.canDirectPlay(t, quality >= 320 ? 0 : quality);
File part = fileOf(t, true), fin = fileOf(t, false);
long have = direct && part.exists() ? part.length() : 0;
if (!direct) part.delete(); // a transcode can't be resumed
String session = direct ? null : "minamp-dl-" + Long.toString(System.currentTimeMillis(), 36);
List<String> urls;
if (direct) { urls = new ArrayList<String>(); urls.add(lib.directUrl(t)); }
else urls = lib.transcodeUrls(t, quality > 0 ? quality : 320, 0, session);
int[] order = direct ? null : Library.variantOrder();
Map<String, String> h = lib.server.mediaHeaders();
if (have > 0) h.put("Range", "bytes=" + have + "-");
Http.Response r = null;
int variant = -1;
String why = null;
for (int i = 0; i < urls.size(); i++) {
if (cancel) return "cancelled";
Http.Response up = Http.get(urls.get(i), h, 30000); // network errors throw: retried later
if (up.code < 400 || up.code == 416) { r = up; variant = i; break; }
String body = "";
try { body = up.readString(); } catch (IOException ignored) {}
why = app.getString(R.string.tr_refused, up.code) + (com.minamp.plex.Plex.snippet(body).length() > 0 ? ": " + com.minamp.plex.Plex.snippet(body) : "");
permanent[0] = up.code < 500;
}
if (r == null) return why;
permanent[0] = false;
if (r.code != 416) {
if (r.code == 200) have = 0; // the server ignored the Range: start over
resp = r;
long expected = r.contentLength >= 0 ? have + r.contentLength : -1;
long got = have;
long started = System.currentTimeMillis(), lastUi = 0;
FileOutputStream out = new FileOutputStream(part, have > 0);
boolean ok = true;
try {
byte[] buf = new byte[16384];
int n;
while ((n = r.body.read(buf)) > 0) {
if (cancel) { ok = false; break; }
out.write(buf, 0, n);
got += n;
long now = System.currentTimeMillis();
if (now - lastUi > 500) {
lastUi = now;
final long g = got, tot = expected;
app.handler.post(new Runnable() { public void run() { e.bytes = g; if (tot > 0) e.total = tot; changed(false); } });
}
}
} catch (IOException io) {
ok = false;
} finally {
out.close();
Http.abort(r);
resp = null;
}
if (session != null) lib.stopTranscode(Library.variantSession(session, order[variant]));
if (cancel) return "cancelled";
if (!ok || (expected >= 0 && got != expected)) return app.getString(R.string.stream_interrupted);
DiagLog.log("downloaded " + t.title + " (" + size(got) + ", " + (direct ? "original" : "transcoded") + ") in "
+ (System.currentTimeMillis() - started) / 1000 + "s");
} else {
Http.abort(r);
}
fin.delete();
if (!part.renameTo(fin)) return "can't save " + fin.getName();
final long size = fin.length();
app.handler.post(new Runnable() { public void run() { e.bytes = size; e.total = size; } });
return null;
}
private void finished(Entry e, String err, boolean permanent) {
if (byKey.get(e.item.ratingKey) != e) { // removed meanwhile
fileOf(e.item, false).delete();
fileOf(e.item, true).delete();
} else if (err == null) {
e.state = DONE;
e.error = null;
} else if ("cancelled".equals(err)) {
e.state = QUEUED;
} else if (permanent || ++e.tries >= MAX_TRIES) {
e.state = FAILED;
e.error = err;
DiagLog.log("download failed: " + e.item.title + ": " + err);
} else {
// probably the network: wait a bit (or for a connection change) and carry on
e.state = QUEUED;
e.error = app.getString(R.string.dl_retrying);
DiagLog.log("download interrupted (" + err + "), will retry: " + e.item.title);
waitUntil = System.currentTimeMillis() + 30000;
app.handler.postDelayed(new Runnable() { public void run() { pump(); } }, 31000);
}
active = null;
save();
changed(true);
pump();
}
}
+136
View File
@@ -0,0 +1,136 @@
package com.minamp;
import java.util.ArrayList;
import java.util.List;
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;
/** Tracks saved on the phone (they play offline) and downloads still in progress. */
public class DownloadsActivity extends BaseActivity implements Downloads.Listener {
private ItemAdapter adapter;
private final List<Downloads.Entry> pending = new ArrayList<Downloads.Entry>();
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setContentView(R.layout.activity_queue);
setupTopBar(str(R.string.downloads), "", true);
// one bar action only: "Downloads" needs the room at the largest text size
barAction(R.id.btn_action2, Glyph.MORE, new View.OnClickListener() {
public void onClick(View v) { moreMenu(); }
});
ListView list = (ListView) findViewById(R.id.list);
adapter = new ItemAdapter(this, ItemAdapter.MODE_DEFAULT);
adapter.setDecorator(new ItemAdapter.Decorator() {
public String sub(Item it) { return status(it); }
});
list.setAdapter(adapter);
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
public void onItemClick(AdapterView<?> p, View v, int pos, long id) {
Item it = adapter.getItem(pos);
Downloads.Entry e = Downloads.get().entry(it);
if (e == null) return;
if (e.state == Downloads.FAILED) { Downloads.get().retry(it); return; }
if (e.state != Downloads.DONE) return;
List<Item> done = Downloads.get().doneTracks();
int start = 0;
for (int i = 0; i < done.size(); i++) if (done.get(i).ratingKey.equals(it.ratingKey)) start = i;
playAll(false, start);
}
});
list.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
public boolean onItemLongClick(AdapterView<?> p, View v, int pos, long id) {
final Item it = adapter.getItem(pos);
if (it.type != Item.TRACK) return false;
new AlertDialog.Builder(DownloadsActivity.this).setTitle(it.title)
.setItems(new String[] {str(R.string.play_next), str(R.string.add_to_queue), str(R.string.remove_download)},
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
if (w == 2) Downloads.get().remove(it);
else itemAction(it, w + 1);
}
}).show();
return true;
}
});
}
@Override
protected void onResume() {
super.onResume();
Downloads.get().addListener(this);
refresh();
}
@Override
protected void onPause() {
super.onPause();
Downloads.get().removeListener(this);
}
public void onDownloadsChanged() { refresh(); }
private String status(Item it) {
Downloads.Entry e = Downloads.get().entry(it);
if (e == null || e.state == Downloads.DONE) return null;
if (e.state == Downloads.FAILED) return str(R.string.dl_failed, e.error == null ? "?" : e.error);
if (e.state == Downloads.DOWNLOADING) {
long total = e.total > 0 ? e.total : it.duration / 1000 * 40000;
int pct = total > 0 ? (int) Math.min(99, e.bytes * 100 / total) : 0;
return str(R.string.dl_progress, pct);
}
return e.error != null ? e.error : str(R.string.dl_waiting);
}
private void refresh() {
Downloads dl = Downloads.get();
List<Item> rows = new ArrayList<Item>();
pending.clear();
pending.addAll(dl.pending());
if (!pending.isEmpty()) {
rows.add(header(str(R.string.dl_in_progress)));
for (int i = 0; i < pending.size(); i++) rows.add(pending.get(i).item);
}
List<Item> done = dl.doneTracks();
if (!done.isEmpty()) {
rows.add(header(str(R.string.dl_done_section)));
rows.addAll(done);
}
adapter.set(rows, -1);
long free = dl.freeSpace();
setBarSub(str(R.string.dl_summary, done.size(), Downloads.size(dl.bytesOnDisk()), free >= 0 ? Downloads.size(free) : "?"));
if (rows.isEmpty()) showStatus(false, str(R.string.dl_empty), null); else hideStatus();
}
private static Item header(String title) {
Item h = new Item();
h.type = Item.HEADER;
h.title = title;
return h;
}
private void playAll(boolean shuffle, int start) {
playList(Downloads.get().doneTracks(), shuffle ? -1 : start, shuffle, str(R.string.downloads));
}
private void moreMenu() {
new AlertDialog.Builder(this)
.setItems(new String[] {str(R.string.play), str(R.string.shuffle), str(R.string.cancel_downloads), str(R.string.delete_all_downloads)},
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
if (w == 0) playAll(false, 0);
else if (w == 1) playAll(true, 0);
else if (w == 2) Downloads.get().cancelPending();
else confirmDeleteDownloads();
}
}).show();
}
}
+1
View File
@@ -90,6 +90,7 @@ public class HomeActivity extends BaseActivity {
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
if (intent.getBooleanExtra("exit", false)) { finish(); return; } // "Close Minamp"
if (intent.getBooleanExtra("relaunch", false)) {
// language changed: rebuild from scratch so every inflated text is re-read
finish();
+10 -2
View File
@@ -20,15 +20,19 @@ public class ItemAdapter extends BaseAdapter {
public interface Pager { void loadMore(int from); }
/** Replaces a row's second line (download progress, errors); null keeps the usual one. */
public interface Decorator { String sub(Item it); }
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 final int accent, text, sub2;
private int total = -1; // total available on the server, -1 = everything loaded
private boolean loadingMore;
private Pager pager;
private Decorator decorator;
private String albumArtist = "";
private int highlight = -1; // queue index highlight
private final App app;
@@ -40,10 +44,12 @@ public class ItemAdapter extends BaseAdapter {
artPx = Ui.dimen(c, R.dimen.row_art);
accent = c.getResources().getColor(R.color.accent);
text = c.getResources().getColor(R.color.text);
sub2 = c.getResources().getColor(R.color.text2);
app = App.get();
}
public void setPager(Pager p) { pager = p; }
public void setDecorator(Decorator d) { decorator = d; }
public void setAlbumArtist(String a) { albumArtist = a == null ? "" : a; }
public void setHighlight(int i) { highlight = i; notifyDataSetChanged(); }
@@ -132,7 +138,7 @@ public class ItemAdapter extends BaseAdapter {
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();
String sub = decorator != null && decorator.sub(it) != null ? decorator.sub(it) : it.subtitle();
h.sub.setVisibility(sub.length() > 0 ? View.VISIBLE : View.GONE);
h.sub.setText(sub);
}
@@ -140,6 +146,8 @@ public class ItemAdapter extends BaseAdapter {
String right = "";
if (it.type == Item.TRACK && it.duration > 0) right = Ui.time(it.duration);
h.right.setText(right);
// downloaded tracks: the duration in the accent colour
h.right.setTextColor(it.type == Item.TRACK && Downloads.get().isDownloaded(it) ? accent : sub2);
boolean current;
if (mode == MODE_QUEUE) current = pos == highlight;
+36 -1
View File
@@ -89,6 +89,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
public static String sourceLabel() {
PlayerService s = instance;
if (s == null || s.track == null) return "";
if (s.localFile) return s.getString(R.string.downloaded);
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" : ""));
@@ -134,6 +135,10 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private long lastTimeline;
private long lastSave;
private long lastPos; // last position seen while playing (getCurrentPosition is unreliable after completion)
private boolean localFile; // playing a download from the SD card
private long stallPos = -1; // position last seen moving
private long stallSince;
private boolean stallWarned;
private AudioManager audio;
private boolean hasFocus;
@@ -265,6 +270,17 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
setState(BUFFERING, getString(R.string.connecting));
goForeground();
if (play) requestFocus();
// Downloaded: play the file on the SD card, no network needed (offline too)
final File saved = Downloads.get().fileFor(it);
localFile = saved != null;
if (saved != null) {
transcoding = false;
session = null;
variantOrder = null;
kbps = 0;
begin(new java.util.ArrayList<String>(), "mp3", saved, 0, posMs);
return;
}
final boolean download = forceDownload || app.downloadFirst();
worker.execute(new Runnable() {
public void run() {
@@ -605,6 +621,16 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (state == PLAYING && track != null) {
long pos = currentPosition();
lastPos = pos;
// "playing" but not moving for 12 s: say so instead of staying silent
if (pos != stallPos) {
stallPos = pos;
stallSince = now;
stallWarned = false;
} else if (!stallWarned && now - stallSince > 12000) {
stallWarned = true;
com.minamp.net.DiagLog.log("playback stalled at " + pos / 1000 + "s");
toast(getString(R.string.stalled));
}
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; }
@@ -631,7 +657,16 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
scrobbled = true;
final App app = App.get();
reporter.execute(new Runnable() {
public void run() { if (app.library != null) app.library.scrobble(it); }
public void run() {
if (app.library == null) return;
// offline (a download): keep the play and report it when the server is back
boolean online = app.library.server.active() != null;
if (!online) {
try { app.library.server.base(); online = true; } catch (Exception ignored) {}
}
if (online) app.library.scrobble(it);
else app.addPendingScrobble(it.ratingKey);
}
});
}
+57 -31
View File
@@ -134,10 +134,14 @@ public final class Server {
public volatile String lastGoodUri = "";
private String probe(Conn c) {
boolean lan = c.kind == LOCAL || c.kind == LOCAL_HTTP || (c.kind == MANUAL && isPrivateAddress(c.uri));
return probe(c, lan ? 5000 : 15000);
}
private String probe(Conn c, int timeoutMs) {
long t0 = System.currentTimeMillis();
try {
boolean lan = c.kind == LOCAL || c.kind == LOCAL_HTTP || (c.kind == MANUAL && isPrivateAddress(c.uri));
Json.Obj o = Plex.fetchJson("GET", c.uri + "/identity", token, lan ? 5000 : 15000);
Json.Obj o = Plex.fetchJson("GET", c.uri + "/identity", token, timeoutMs);
Json.Obj mc = o.optObj("MediaContainer");
String id = mc == null ? "" : mc.optString("machineIdentifier");
if (machineId.length() > 0 && !machineId.equals(id)) return "a different server answered";
@@ -164,6 +168,39 @@ public final class Server {
return c;
}
/** Probes conns in parallel; the first that answers wins (null if none within waitMs). */
private Conn firstOf(final List<Conn> conns, final int timeoutMs, long waitMs, String[] firstError) {
final String[] errs = new String[conns.size()];
final int[] state = new int[conns.size()];
final Object lock = new Object();
for (int i = 0; i < conns.size(); i++) {
final int idx = i;
com.minamp.net.Threads.start(new Runnable() {
public void run() {
String e = probe(conns.get(idx), timeoutMs);
synchronized (lock) { errs[idx] = e; state[idx] = e == null ? 1 : 2; lock.notifyAll(); }
}
}, "probe-" + i);
}
long deadline = System.currentTimeMillis() + waitMs;
synchronized (lock) {
while (true) {
boolean pending = false;
for (int i = 0; i < conns.size(); i++) {
if (state[i] == 1) return conns.get(i);
if (state[i] == 0) pending = true;
}
long left = deadline - System.currentTimeMillis();
if (!pending || left <= 0) break;
try { lock.wait(left); } catch (InterruptedException e) { break; }
}
for (int i = 0; i < conns.size(); i++) {
if (errs[i] != null && firstError[0] == null) firstError[0] = kindLabel(conns.get(i).kind) + ": " + errs[i];
}
}
return null;
}
/**
* Finds a working connection, cheapest first, never running more than one TLS handshake at a
* time (parallel handshakes on a single slow core make every one of them time out):
@@ -184,6 +221,19 @@ public final class Server {
String firstError = null;
List<Conn> tried = new ArrayList<Conn>();
// 0. Last time we were away: if we're home again, the home address beats the remote one
// (which often still works from inside, through the router, but encrypted and capped).
Conn last = null;
for (int i = 0; i < order.size(); i++) if (order.get(i).uri.equals(lastGoodUri)) last = order.get(i);
if (last != null && !isPlainLan(last) && last.kind != LOCAL && !preferSecureAtHome) {
List<Conn> home = new ArrayList<Conn>();
for (int i = 0; i < order.size(); i++) if (isPlainLan(order.get(i))) home.add(order.get(i));
if (!home.isEmpty()) {
Conn w = firstOf(home, 2000, 2500, new String[1]);
if (w != null) return choose(w);
}
}
// 1. last known good
for (int i = 0; i < order.size(); i++) {
Conn c = order.get(i);
@@ -202,35 +252,11 @@ public final class Server {
for (int i = 0; i < order.size(); i++) if (isPlainLan(order.get(i)) && !tried.contains(order.get(i))) plain.add(order.get(i));
}
if (!plain.isEmpty()) {
final String[] errs = new String[plain.size()];
final int[] state = new int[plain.size()];
final Object lock = new Object();
for (int i = 0; i < plain.size(); i++) {
final int idx = i;
com.minamp.net.Threads.start(new Runnable() {
public void run() {
String e = probe(plain.get(idx));
synchronized (lock) { errs[idx] = e; state[idx] = e == null ? 1 : 2; lock.notifyAll(); }
}
}, "probe-" + i);
}
long deadline = System.currentTimeMillis() + 7000;
synchronized (lock) {
while (true) {
boolean pending = false;
for (int i = 0; i < plain.size(); i++) {
if (state[i] == 1) return choose(plain.get(i));
if (state[i] == 0) pending = true;
}
long left = deadline - System.currentTimeMillis();
if (!pending || left <= 0) break;
try { lock.wait(left); } catch (InterruptedException e) { break; }
}
}
for (int i = 0; i < plain.size(); i++) {
tried.add(plain.get(i));
if (firstError == null && errs[i] != null) firstError = kindLabel(plain.get(i).kind) + ": " + errs[i];
}
String[] err = new String[1];
Conn w = firstOf(plain, 5000, 7000, err);
if (w != null) return choose(w);
tried.addAll(plain);
if (firstError == null) firstError = err[0];
}
// 3. everything else, one at a time, in preference order