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"?>
|
<?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" />
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user