Private
Public Access
Merge branch 'read-ahead-2' into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
30cc96cb34
6 files changed
+136
-37
No files matched your search
+2
-2
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user