Merge branch 'read-ahead-2' into main

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:02:37 +02:00
commit 30cc96cb34
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"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.minamp" package="com.minamp"
android:versionCode="13" android:versionCode="14"
android:versionName="1.12" android:versionName="1.13"
android:installLocation="internalOnly"> android:installLocation="internalOnly">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" /> <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 (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. 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. * 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 * **Read-ahead:** while a track plays, the next two in the queue are fetched whole in the background
played from the file. Android 2.x's player keeps only a few seconds of stream buffer, so on weak (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. 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 * 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 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 // ------------------------------------------------------------------ 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 { static final class AssetRoots implements CertVerifier.RootStore {
private final Context ctx; private final Context ctx;
private Set<String> names; 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; } AssetRoots(Context c) { ctx = c; }
public synchronized List<byte[]> find(byte[] subjectDer) { public synchronized List<byte[]> find(byte[] subjectDer) {
String h = CertVerifier.sha1Hex(subjectDer);
if (found.containsKey(h)) return found.get(h);
try { try {
if (names == null) { if (names == null) {
names = new HashSet<String>();
String[] l = ctx.getAssets().list("roots"); 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; List<byte[]> out = null;
for (int i = 0; names.contains(h + "-" + i + ".der"); i++) { for (int i = 0; names.contains(h + "-" + i + ".der"); i++) {
if (out == null) out = new ArrayList<byte[]>(); if (out == null) out = new ArrayList<byte[]>();
out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der"))); out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der")));
} }
found.put(h, out);
return out; return out;
} catch (IOException e) { } 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; 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. */ /** Replace the queue. If shuffling, the chosen start track plays first. */
public void set(List<Item> list, int start, boolean shuffled, String sourceTitle) { public void set(List<Item> list, int start, boolean shuffled, String sourceTitle) {
items.clear(); items.clear();
+82 -28
View File
@@ -149,15 +149,22 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private boolean btOn; private boolean btOn;
private long failedAtPos = -1; 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 // 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). // 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 volatile boolean prefetchBusy;
private String prefetchKey; // track in prefetchFile (complete), main thread private String prefetchingKey; // track being fetched, main thread
private File prefetchFile; private volatile Http.Response prefetchResponse; // so a fetch the queue no longer needs can be cut off
private String prefetchExt; private int aheadSerial;
private int prefetchKbps; private boolean prefetchAborted; // the running fetch was cut off on purpose, main thread
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 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 WifiManager.WifiLock wifiLock;
private boolean noisyRegistered; private boolean noisyRegistered;
private boolean foreground; private boolean foreground;
@@ -197,6 +204,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE); TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
tm.listen(phoneListener, PhoneStateListener.LISTEN_CALL_STATE); tm.listen(phoneListener, PhoneStateListener.LISTEN_CALL_STATE);
} catch (Exception ignored) {} } catch (Exception ignored) {}
clearAheadFiles();
App.get().handler.post(ticker); 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); begin(new java.util.ArrayList<String>(), "mp3", saved, 0, posMs);
return; 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"); com.minamp.net.DiagLog.log("playing \"" + it.title + "\" from the read-ahead file");
transcoding = false; transcoding = false;
session = null; session = null;
variantOrder = null; variantOrder = null;
kbps = prefetchKbps; kbps = pre.kbps;
playingPrefetch = prefetchFile; playingPrefetch = pre.file;
prefetchKey = null; begin(new java.util.ArrayList<String>(), pre.ext, playingPrefetch, 0, posMs);
begin(new java.util.ArrayList<String>(), prefetchExt, playingPrefetch, 0, posMs);
return; return;
} }
playingPrefetch = null;
final boolean download = forceDownload || app.downloadFirst(); final boolean download = forceDownload || app.downloadFirst();
worker.execute(new Runnable() { worker.execute(new Runnable() {
public void run() { 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, /** Fetches the next AHEAD tracks whole in the background, one at a time (Wi-Fi or mobile data: it's the
* only earlier; a skipped track is the only waste), if it isn't here yet. Main thread. */ * same bytes as streaming, only earlier; a skipped track is the only waste). Main thread. */
private void maybePrefetch() { private void maybePrefetch() {
final App app = App.get(); final App app = App.get();
if (prefetchBusy || app.library == null) return; if (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
File base = null; File base = null;
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = getExternalCacheDir(); if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = getExternalCacheDir();
boolean sd = base != null;
if (base == null) base = getCacheDir(); if (base == null) base = getCacheDir();
File a = new File(base, "ahead-a.tmp"), b = new File(base, "ahead-b.tmp"); // without an SD card the files go to the small internal storage: keep one track there, not two
final File target = a.equals(playingPrefetch) ? b : a; 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; prefetchBusy = true;
prefetchKey = null; prefetchingKey = next.ratingKey;
prefetchAborted = false;
com.minamp.net.Threads.start(new Runnable() { com.minamp.net.Threads.start(new Runnable() {
public void run() { public void run() {
String ext = "mp3"; String ext = "mp3";
@@ -388,6 +421,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
for (String u : urls) { for (String u : urls) {
Http.Response r = Http.get(u, lib.server.mediaHeaders(), 30000); Http.Response r = Http.get(u, lib.server.mediaHeaders(), 30000);
if (r.code != 200) { r.close(); continue; } if (r.code != 200) { r.close(); continue; }
prefetchResponse = r;
FileOutputStream out = new FileOutputStream(target); FileOutputStream out = new FileOutputStream(target);
long got = 0; long got = 0;
try { try {
@@ -395,6 +429,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
int n; int n;
while ((n = r.body.read(buf)) > 0) { out.write(buf, 0, n); got += n; } while ((n = r.body.read(buf)) > 0) { out.write(buf, 0, n); got += n; }
} finally { } finally {
prefetchResponse = null;
out.close(); out.close();
r.close(); r.close();
} }
@@ -405,25 +440,44 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
break; break;
} }
} catch (Throwable e) { } 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 boolean done = ok;
final String fext = ext; final String fext = ext;
final int fk = k; final int fk = k;
post(new Runnable() { post(new Runnable() {
public void run() { public void run() {
prefetchBusy = false; prefetchBusy = false;
if (!done) return; prefetchingKey = null;
prefetchKey = next.ratingKey; if (!done) { if (!prefetchAborted) aheadRetryAt = System.currentTimeMillis() + 30000; return; }
prefetchFile = target; aheadRetryAt = 0;
prefetchExt = fext; Ahead a = new Ahead();
prefetchKbps = fk; 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"); }, "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) { private static String extFor(Item it) {
String c = it.container.toLowerCase(); String c = it.container.toLowerCase();
if ("mp4".equals(c) || "m4a".equals(c) || "m4b".equals(c)) return "m4a"; 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(); 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) { public static void main(String[] a) {
PlayQueue q = PlayQueue.get(); PlayQueue q = PlayQueue.get();
q.set(tracks(5), 2, false, "Album"); 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"); check(q.size() == 10 && q.index() == 0 && q.shuffle(), "shuffle play from random start");
q.clear(); q.clear();
check(q.current() == null && q.size() == 0, "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.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED");
System.exit(fails == 0 ? 0 : 1); System.exit(fails == 0 ? 0 : 1);
} }