Private
Public Access
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:
1 parent
f7f49ecfbb
commit
f9050bbac0
15 files changed
+933
-43
No files matched your search
@@ -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"); }
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user