Plexamp Lite 1.13: read ahead the next two tracks, keep loaded roots

Read-ahead keeps the next two tracks on the SD card (one without it) and drops files
that are no longer coming up. Trusted roots are cached once read, and a failed read
is retried instead of leaving every server untrusted until a restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XoséandClaude Opus 5.5 committed 2026-10-20 14:00:46 +02:00
1 parent 30d7742fb5
commit b6f696a8c8
6 files changed
+136 -37

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.minamp"
android:versionCode="13"
android:versionName="1.12"
android:versionCode="14"
android:versionName="1.13"
android:installLocation="internalOnly">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
+2 -2
View File
@@ -61,8 +61,8 @@ clock has been reset to before 2024 the certificate date check is skipped rather
(default 192 kbps) caps both while the sound goes to a Bluetooth speaker: on the Mini, Wi-Fi and
Bluetooth share one radio, and a lighter stream keeps the speaker from stuttering.
* Seeking in a transcoded track restarts the transcode at the new position.
* **Read-ahead:** while a track plays, the next one is fetched whole in the background and then
played from the file. Android 2.x's player keeps only a few seconds of stream buffer, so on weak
* **Read-ahead:** while a track plays, the next two in the queue are fetched whole in the background
(one at a time) and then played from the file; without an SD card only the next one is kept. Android 2.x's player keeps only a few seconds of stream buffer, so on weak
Wi-Fi shared with a Bluetooth speaker it used to go silent for seconds at a time.
* If a stream fails, Plexamp Lite re-picks the connection and resumes; if it fails again it downloads
the whole track first and plays from the SD card. "Download tracks before playing" forces that
+11 -5
View File
@@ -317,29 +317,35 @@ public class App extends Application {
// ------------------------------------------------------------------ trust store
/** Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der */
/** Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der, read once and kept: a failed read
* must not leave every server "untrusted" until the app restarts. */
static final class AssetRoots implements CertVerifier.RootStore {
private final Context ctx;
private Set<String> names;
private final java.util.Map<String, List<byte[]>> found = new java.util.HashMap<String, List<byte[]>>(); // null = none
AssetRoots(Context c) { ctx = c; }
public synchronized List<byte[]> find(byte[] subjectDer) {
String h = CertVerifier.sha1Hex(subjectDer);
if (found.containsKey(h)) return found.get(h);
try {
if (names == null) {
names = new HashSet<String>();
String[] l = ctx.getAssets().list("roots");
if (l != null) for (int i = 0; i < l.length; i++) names.add(l[i]);
if (l == null || l.length == 0) return null; // try again next time
Set<String> n = new HashSet<String>();
for (int i = 0; i < l.length; i++) n.add(l[i]);
names = n;
}
String h = CertVerifier.sha1Hex(subjectDer);
List<byte[]> out = null;
for (int i = 0; names.contains(h + "-" + i + ".der"); i++) {
if (out == null) out = new ArrayList<byte[]>();
out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der")));
}
found.put(h, out);
return out;
} catch (IOException e) {
return null;
return null; // not cached: the next handshake reads again
}
}
}
+21
View File
@@ -60,6 +60,27 @@ public final class PlayQueue {
return repeat == REPEAT_ALL ? items.get(0) : null;
}
/** Up to n tracks that play after the current one, in order (wrapping round with repeat-all),
* without the current track or repeats of the same track. */
public List<Item> peekAhead(int n) {
ArrayList<Item> out = new ArrayList<Item>();
Item cur = current();
int size = items.size();
for (int step = 1; step < size + 1 && out.size() < n; step++) {
int i = index + step;
if (i >= size) {
if (repeat != REPEAT_ALL) break;
i %= size;
}
Item it = items.get(i);
if (it == cur || (cur != null && it.ratingKey.equals(cur.ratingKey))) continue;
boolean dup = false;
for (int j = 0; j < out.size(); j++) if (out.get(j).ratingKey.equals(it.ratingKey)) dup = true;
if (!dup) out.add(it);
}
return out;
}
/** Replace the queue. If shuffling, the chosen start track plays first. */
public void set(List<Item> list, int start, boolean shuffled, String sourceTitle) {
items.clear();
+82 -28
View File
@@ -149,15 +149,22 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private boolean btOn;
private long failedAtPos = -1;
// Read-ahead: the next track is fetched whole while this one plays, then played from the file.
// Read-ahead: the next tracks are fetched whole while this one plays, then played from the file.
// Android 2.x's player keeps only seconds of stream buffered; on weak Wi-Fi shared with Bluetooth
// audio it runs dry and goes silent for 6-7 s at a time (and some speakers then hang up).
// Two tracks ahead, so one slow or failed fetch doesn't leave the following track streaming.
private static final int AHEAD = 2;
private static final class Ahead { File file; String ext; int kbps; }
private final java.util.HashMap<String, Ahead> ahead = new java.util.HashMap<String, Ahead>(); // ratingKey -> complete file, main thread
private volatile boolean prefetchBusy;
private String prefetchKey; // track in prefetchFile (complete), main thread
private File prefetchFile;
private String prefetchExt;
private int prefetchKbps;
private File playingPrefetch; // the prefetched file being played: not to be overwritten // where the last failure/stall was recovered from // audio was going to Bluetooth (A2DP), as last seen while playing
private String prefetchingKey; // track being fetched, main thread
private volatile Http.Response prefetchResponse; // so a fetch the queue no longer needs can be cut off
private int aheadSerial;
private boolean prefetchAborted; // the running fetch was cut off on purpose, main thread
private long aheadRetryAt; // after a failed fetch, don't try again (a TLS handshake each time) until then
private File playingPrefetch; // the prefetched file being played: not to be deleted
private Ahead playingAhead;
private String playingAheadKey;
private WifiManager.WifiLock wifiLock;
private boolean noisyRegistered;
private boolean foreground;
@@ -197,6 +204,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
tm.listen(phoneListener, PhoneStateListener.LISTEN_CALL_STATE);
} catch (Exception ignored) {}
clearAheadFiles();
App.get().handler.post(ticker);
}
@@ -295,18 +303,23 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
begin(new java.util.ArrayList<String>(), "mp3", saved, 0, posMs);
return;
}
if (prefetchKey != null && prefetchKey.equals(it.ratingKey) && prefetchFile != null && prefetchFile.exists()) {
Ahead pre = ahead.remove(it.ratingKey);
if (pre == null && playingPrefetch != null && it.ratingKey.equals(playingAheadKey)) pre = playingAhead; // same track again
if (pre != null && !pre.file.exists()) pre = null;
if (playingPrefetch != null && (pre == null || !playingPrefetch.equals(pre.file))) playingPrefetch.delete();
playingPrefetch = null;
playingAhead = pre;
playingAheadKey = pre == null ? null : it.ratingKey;
if (pre != null) {
com.minamp.net.DiagLog.log("playing \"" + it.title + "\" from the read-ahead file");
transcoding = false;
session = null;
variantOrder = null;
kbps = prefetchKbps;
playingPrefetch = prefetchFile;
prefetchKey = null;
begin(new java.util.ArrayList<String>(), prefetchExt, playingPrefetch, 0, posMs);
kbps = pre.kbps;
playingPrefetch = pre.file;
begin(new java.util.ArrayList<String>(), pre.ext, playingPrefetch, 0, posMs);
return;
}
playingPrefetch = null;
final boolean download = forceDownload || app.downloadFirst();
worker.execute(new Runnable() {
public void run() {
@@ -353,22 +366,42 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
});
}
/** Fetches the next track whole in the background (Wi-Fi or mobile data: it's the same bytes as streaming,
* only earlier; a skipped track is the only waste), if it isn't here yet. Main thread. */
/** Fetches the next AHEAD tracks whole in the background, one at a time (Wi-Fi or mobile data: it's the
* same bytes as streaming, only earlier; a skipped track is the only waste). Main thread. */
private void maybePrefetch() {
final App app = App.get();
if (prefetchBusy || app.library == null) return;
PlayQueue q = PlayQueue.get();
final Item next = q.peekNext();
if (next == null || next == track || next.ratingKey.equals(prefetchKey) || Downloads.get().fileFor(next) != null) return;
// two slots: never overwrite the file that's playing
if (app.library == null) return;
File base = null;
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = getExternalCacheDir();
boolean sd = base != null;
if (base == null) base = getCacheDir();
File a = new File(base, "ahead-a.tmp"), b = new File(base, "ahead-b.tmp");
final File target = a.equals(playingPrefetch) ? b : a;
// without an SD card the files go to the small internal storage: keep one track there, not two
java.util.List<Item> upcoming = PlayQueue.get().peekAhead(sd ? AHEAD : 1);
java.util.HashSet<String> wanted = new java.util.HashSet<String>();
for (int i = 0; i < upcoming.size(); i++) wanted.add(upcoming.get(i).ratingKey);
// forget what the queue no longer needs, and stop fetching it
for (java.util.Iterator<java.util.Map.Entry<String, Ahead>> e = ahead.entrySet().iterator(); e.hasNext();) {
java.util.Map.Entry<String, Ahead> en = e.next();
if (!wanted.contains(en.getKey())) { en.getValue().file.delete(); e.remove(); }
}
if (prefetchBusy) {
if (prefetchingKey != null && !wanted.contains(prefetchingKey)) { prefetchAborted = true; Http.abort(prefetchResponse); }
return;
}
Item pick = null;
for (int i = 0; i < upcoming.size() && pick == null; i++) {
Item it = upcoming.get(i);
if (it == track || ahead.containsKey(it.ratingKey) || Downloads.get().fileFor(it) != null) continue;
pick = it;
}
if (pick == null || System.currentTimeMillis() < aheadRetryAt) return;
final Item next = pick;
final File target = new File(base, "ahead-" + (++aheadSerial) + ".tmp");
prefetchBusy = true;
prefetchKey = null;
prefetchingKey = next.ratingKey;
prefetchAborted = false;
com.minamp.net.Threads.start(new Runnable() {
public void run() {
String ext = "mp3";
@@ -388,6 +421,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
for (String u : urls) {
Http.Response r = Http.get(u, lib.server.mediaHeaders(), 30000);
if (r.code != 200) { r.close(); continue; }
prefetchResponse = r;
FileOutputStream out = new FileOutputStream(target);
long got = 0;
try {
@@ -395,6 +429,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
int n;
while ((n = r.body.read(buf)) > 0) { out.write(buf, 0, n); got += n; }
} finally {
prefetchResponse = null;
out.close();
r.close();
}
@@ -405,25 +440,44 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
break;
}
} catch (Throwable e) {
com.minamp.net.DiagLog.log("read ahead failed: " + com.minamp.net.Threads.describe(e));
prefetchResponse = null;
com.minamp.net.DiagLog.log("read ahead \"" + next.title + "\" failed: " + com.minamp.net.Threads.describe(e));
}
if (!ok) target.delete();
final boolean done = ok;
final String fext = ext;
final int fk = k;
post(new Runnable() {
public void run() {
prefetchBusy = false;
if (!done) return;
prefetchKey = next.ratingKey;
prefetchFile = target;
prefetchExt = fext;
prefetchKbps = fk;
prefetchingKey = null;
if (!done) { if (!prefetchAborted) aheadRetryAt = System.currentTimeMillis() + 30000; return; }
aheadRetryAt = 0;
Ahead a = new Ahead();
a.file = target;
a.ext = fext;
a.kbps = fk;
Ahead old = ahead.put(next.ratingKey, a);
if (old != null && !old.file.equals(target)) old.file.delete();
}
});
}
}, "ahead");
}
/** Read-ahead files left over from an earlier run (the app was killed mid-track). */
private void clearAheadFiles() {
File[] dirs = {getCacheDir(), null};
try {
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) dirs[1] = getExternalCacheDir();
} catch (Exception ignored) {}
for (int d = 0; d < dirs.length; d++) {
File[] fs = dirs[d] == null ? null : dirs[d].listFiles();
if (fs == null) continue;
for (int i = 0; i < fs.length; i++) if (fs[i].getName().startsWith("ahead-")) fs[i].delete();
}
}
private static String extFor(Item it) {
String c = it.container.toLowerCase();
if ("mp4".equals(c) || "m4a".equals(c) || "m4b".equals(c)) return "m4a";
+18
View File
@@ -25,6 +25,12 @@ public class QueueTest {
return sb.toString().trim();
}
static String titles(List<Item> l) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < l.size(); i++) sb.append(i > 0 ? " " : "").append(l.get(i).title);
return sb.toString();
}
public static void main(String[] a) {
PlayQueue q = PlayQueue.get();
q.set(tracks(5), 2, false, "Album");
@@ -65,6 +71,18 @@ public class QueueTest {
check(q.size() == 10 && q.index() == 0 && q.shuffle(), "shuffle play from random start");
q.clear();
check(q.current() == null && q.size() == 0, "clear");
// read-ahead window: the next two tracks, wrapping only with repeat all, never the current one
q.set(tracks(4), 1, false, "Ahead");
check(titles(q.peekAhead(2)).equals("T2 T3"), "peekAhead 2 from T1: " + titles(q.peekAhead(2)));
q.advance(false); q.advance(false);
check(titles(q.peekAhead(2)).equals(""), "peekAhead at the end, repeat off: empty");
q.cycleRepeat(); // all
check(titles(q.peekAhead(2)).equals("T0 T1"), "peekAhead wraps with repeat all: " + titles(q.peekAhead(2)));
q.cycleRepeat(); q.cycleRepeat(); // off
q.set(tracks(2), 0, false, "Two");
q.cycleRepeat(); // all
check(titles(q.peekAhead(2)).equals("T1"), "peekAhead skips the current track on wrap: " + titles(q.peekAhead(2)));
q.cycleRepeat(); q.cycleRepeat(); // off
System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED");
System.exit(fails == 0 ? 0 : 1);
}