From b6f696a8c8b49353664dfc057aa3c09b90fb2883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Tue, 20 Oct 2026 14:00:46 +0200 Subject: [PATCH] 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 --- AndroidManifest.xml | 4 +- README.md | 4 +- src/com/minamp/App.java | 16 +++-- src/com/minamp/PlayQueue.java | 21 ++++++ src/com/minamp/PlayerService.java | 110 ++++++++++++++++++++++-------- test/QueueTest.java | 18 +++++ 6 files changed, 136 insertions(+), 37 deletions(-) diff --git a/AndroidManifest.xml b/AndroidManifest.xml index b9812cc..2552af7 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@ diff --git a/README.md b/README.md index 3be4a28..09fa410 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/com/minamp/App.java b/src/com/minamp/App.java index 286d194..5c0fba8 100644 --- a/src/com/minamp/App.java +++ b/src/com/minamp/App.java @@ -317,29 +317,35 @@ public class App extends Application { // ------------------------------------------------------------------ trust store - /** Mozilla roots shipped as assets/roots/-.der */ + /** Mozilla roots shipped as assets/roots/-.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 names; + private final java.util.Map> found = new java.util.HashMap>(); // null = none AssetRoots(Context c) { ctx = c; } public synchronized List find(byte[] subjectDer) { + String h = CertVerifier.sha1Hex(subjectDer); + if (found.containsKey(h)) return found.get(h); try { if (names == null) { - names = new HashSet(); 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 n = new HashSet(); + for (int i = 0; i < l.length; i++) n.add(l[i]); + names = n; } - String h = CertVerifier.sha1Hex(subjectDer); List out = null; for (int i = 0; names.contains(h + "-" + i + ".der"); i++) { if (out == null) out = new ArrayList(); 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 } } } diff --git a/src/com/minamp/PlayQueue.java b/src/com/minamp/PlayQueue.java index 4a73d72..3fec190 100644 --- a/src/com/minamp/PlayQueue.java +++ b/src/com/minamp/PlayQueue.java @@ -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 peekAhead(int n) { + ArrayList out = new ArrayList(); + 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 list, int start, boolean shuffled, String sourceTitle) { items.clear(); diff --git a/src/com/minamp/PlayerService.java b/src/com/minamp/PlayerService.java index 9039f6d..b53b3e9 100644 --- a/src/com/minamp/PlayerService.java +++ b/src/com/minamp/PlayerService.java @@ -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 ahead = new java.util.HashMap(); // 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(), "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(), prefetchExt, playingPrefetch, 0, posMs); + kbps = pre.kbps; + playingPrefetch = pre.file; + begin(new java.util.ArrayList(), 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 upcoming = PlayQueue.get().peekAhead(sd ? AHEAD : 1); + java.util.HashSet wanted = new java.util.HashSet(); + 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> e = ahead.entrySet().iterator(); e.hasNext();) { + java.util.Map.Entry 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"; diff --git a/test/QueueTest.java b/test/QueueTest.java index 27dd7c8..ba40d87 100644 --- a/test/QueueTest.java +++ b/test/QueueTest.java @@ -25,6 +25,12 @@ public class QueueTest { return sb.toString().trim(); } + static String titles(List 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); }