From 29ed836b4cfc69c6d2acdf716ea2d7a40b8e9d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sun, 18 Oct 2026 15:25:54 +0200 Subject: [PATCH] Minamp 1.10: read the next track ahead; log to logcat too On weak Wi-Fi shared with Bluetooth audio, Android 2.x's player (a few seconds of stream buffer) ran dry and went silent for 6-7 s at a time; the speaker then sometimes hung up. While a track plays, the next one is fetched whole (Wi-Fi only) and played from the file. DiagLog lines also go to logcat (tag Minamp). Co-Authored-By: Claude Opus 5.5 --- AndroidManifest.xml | 4 +- src/com/minamp/PlayerService.java | 99 ++++++++++++++++++++++++++++++- src/com/minamp/net/DiagLog.java | 1 + 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 3a2d578..116f2b4 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@ diff --git a/src/com/minamp/PlayerService.java b/src/com/minamp/PlayerService.java index 1895a84..fcf8793 100644 --- a/src/com/minamp/PlayerService.java +++ b/src/com/minamp/PlayerService.java @@ -147,7 +147,17 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private boolean pausedByRoute; // the Bluetooth speaker dropped: resume if it's back soon private long routeLostAt; private boolean btOn; - private long failedAtPos = -1; // where the last failure/stall was recovered from // audio was going to Bluetooth (A2DP), as last seen while playing + private long failedAtPos = -1; + + // Read-ahead: the next track is 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). + 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 WifiManager.WifiLock wifiLock; private boolean noisyRegistered; private boolean foreground; @@ -285,6 +295,18 @@ 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()) { + 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); + return; + } + playingPrefetch = null; final boolean download = forceDownload || app.downloadFirst(); worker.execute(new Runnable() { public void run() { @@ -331,6 +353,76 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList }); } + /** Fetches the next track whole in the background (Wi-Fi only), if it isn't here yet. Main thread. */ + private void maybePrefetch() { + final App app = App.get(); + if (prefetchBusy || app.library == null || app.onMobileData()) 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; + if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = getExternalCacheDir(); + 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; + prefetchBusy = true; + prefetchKey = null; + com.minamp.net.Threads.start(new Runnable() { + public void run() { + String ext = "mp3"; + int k = 0; + boolean ok = false; + long t0 = System.currentTimeMillis(); + try { + Library lib = app.library; + lib.ensureMedia(next); + lib.server.base(); + int max = app.currentMaxKbps(); + boolean direct = Library.canDirectPlay(next, max); + java.util.List urls; + if (direct) { urls = new java.util.ArrayList(); urls.add(lib.directUrl(next)); ext = extFor(next); } + else urls = lib.transcodeUrls(next, max, 0, "minamp-ahead-" + Long.toString(System.currentTimeMillis(), 36)); + k = direct ? 0 : (max <= 0 ? 320 : max); + for (String u : urls) { + Http.Response r = Http.get(u, lib.server.mediaHeaders(), 30000); + if (r.code != 200) { r.close(); continue; } + FileOutputStream out = new FileOutputStream(target); + long got = 0; + try { + byte[] buf = new byte[16384]; + int n; + while ((n = r.body.read(buf)) > 0) { out.write(buf, 0, n); got += n; } + } finally { + out.close(); + r.close(); + } + // a stream cut short leaves a truncated file: only a plausible length counts + ok = got > 0 && (r.contentLength <= 0 || got >= r.contentLength); + com.minamp.net.DiagLog.log("read ahead \"" + next.title + "\": " + got / 1024 + " KB in " + + (System.currentTimeMillis() - t0) / 1000 + " s" + (ok ? "" : " (incomplete)")); + break; + } + } catch (Throwable e) { + com.minamp.net.DiagLog.log("read ahead failed: " + com.minamp.net.Threads.describe(e)); + } + 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; + } + }); + } + }, "ahead"); + } + private static String extFor(Item it) { String c = it.container.toLowerCase(); if ("mp4".equals(c) || "m4a".equals(c) || "m4b".equals(c)) return "m4a"; @@ -618,7 +710,10 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private final Runnable ticker = new Runnable() { public void run() { - if (state == PLAYING) btOn = audio.isBluetoothA2dpOn(); + if (state == PLAYING) { + btOn = audio.isBluetoothA2dpOn(); + maybePrefetch(); + } App.get().handler.postDelayed(this, 1000); long now = System.currentTimeMillis(); if (sleepAt > 0 && now >= sleepAt) { diff --git a/src/com/minamp/net/DiagLog.java b/src/com/minamp/net/DiagLog.java index bd6b30a..d087667 100644 --- a/src/com/minamp/net/DiagLog.java +++ b/src/com/minamp/net/DiagLog.java @@ -19,6 +19,7 @@ public final class DiagLog { private DiagLog() {} public static void log(String msg) { + android.util.Log.i("Minamp", msg); // also in logcat: readable over USB after the fact String line; synchronized (fmt) { line = fmt.format(new Date()) + " " + msg; } synchronized (lines) {