From a610b090f0eb0ffc8da5fbc40da6fab6b228ef03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sun, 18 Oct 2026 14:16:56 +0200 Subject: [PATCH 1/3] Minamp 1.9: install internally, resume after Bluetooth dropouts, log why playback stops Installed on the SD card (installLocation auto), Minamp was killed whenever USB storage was turned on. A2DP dropouts send AUDIO_BECOMING_NOISY like an unplug: after a Bluetooth loss, resume if the speaker is back within 20 s. Focus loss, media buttons and stream failures now go to the connection log. Co-Authored-By: Claude Opus 5.5 --- AndroidManifest.xml | 6 ++-- src/com/minamp/MediaButtonReceiver.java | 1 + src/com/minamp/PlayerService.java | 42 ++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/AndroidManifest.xml b/AndroidManifest.xml index d3c498c..3a2d578 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,9 +1,9 @@ + android:versionCode="10" + android:versionName="1.9" + android:installLocation="internalOnly"> diff --git a/src/com/minamp/MediaButtonReceiver.java b/src/com/minamp/MediaButtonReceiver.java index 7de3773..4cb57e7 100644 --- a/src/com/minamp/MediaButtonReceiver.java +++ b/src/com/minamp/MediaButtonReceiver.java @@ -19,6 +19,7 @@ public class MediaButtonReceiver extends BroadcastReceiver { if (ev == null) return; if (isOrderedBroadcast()) abortBroadcast(); if (ev.getAction() != KeyEvent.ACTION_DOWN || ev.getRepeatCount() > 0) return; + com.minamp.net.DiagLog.log("media button " + ev.getKeyCode() + " (headset / Bluetooth)"); final Context app = context.getApplicationContext(); switch (ev.getKeyCode()) { case KeyEvent.KEYCODE_HEADSETHOOK: diff --git a/src/com/minamp/PlayerService.java b/src/com/minamp/PlayerService.java index f4c7ac5..70e9991 100644 --- a/src/com/minamp/PlayerService.java +++ b/src/com/minamp/PlayerService.java @@ -144,6 +144,9 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private boolean hasFocus; private boolean pausedByFocus; private boolean pausedByCall; + private boolean pausedByRoute; // the Bluetooth speaker dropped: resume if it's back soon + private long routeLostAt; + private boolean btOn; // audio was going to Bluetooth (A2DP), as last seen while playing private WifiManager.WifiLock wifiLock; private boolean noisyRegistered; private boolean foreground; @@ -486,6 +489,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList } private void trackFailed(String msg) { + com.minamp.net.DiagLog.log("track failed: " + msg + (refusal != null ? " / " + refusal : "") + " (retry " + (retries + 1) + ")"); long pos = Math.max(currentPosition(), lastPos); if (refusal != null) msg = refusal; // the server's own reason beats "Playback error (1/-1004)" boolean permanent = refusalCode >= 400 && refusalCode < 500 && refusalCode != 408 && refusalCode != 429; @@ -536,6 +540,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private void pause() { pausedByFocus = false; + pausedByRoute = false; playWhenReady = false; if (mp != null && prepared) { try { mp.pause(); } catch (Exception ignored) {} @@ -611,6 +616,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private final Runnable ticker = new Runnable() { public void run() { + if (state == PLAYING) btOn = audio.isBluetoothA2dpOn(); App.get().handler.postDelayed(this, 1000); long now = System.currentTimeMillis(); if (sleepAt > 0 && now >= sleepAt) { @@ -692,10 +698,12 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList if (pausedByFocus) { pausedByFocus = false; play(); } break; case AudioManager.AUDIOFOCUS_LOSS: + com.minamp.net.DiagLog.log("paused: another app took the audio (focus lost)"); hasFocus = false; pause(); break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + com.minamp.net.DiagLog.log("paused for a moment: another app needs the audio"); if (state == PLAYING) { pause(); pausedByFocus = true; } break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: @@ -716,8 +724,40 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList } }; + /** + * Headphones unplugged, or the Bluetooth speaker went away. Android 2.x sends this for any A2DP dropout too + * (Wi-Fi and Bluetooth share one radio on these phones, and cheap speakers drop the link now and then): + * after a Bluetooth loss, keep watching for the speaker and resume if it's back within 20 s. + */ private final BroadcastReceiver noisy = new BroadcastReceiver() { - @Override public void onReceive(Context c, Intent i) { pause(); } + @Override public void onReceive(Context c, Intent i) { + boolean bt = btOn; + com.minamp.net.DiagLog.log("paused: audio output went away (" + (bt ? "Bluetooth speaker dropped" : "headphones unplugged") + ")"); + pause(); + if (bt && track != null) { + pausedByRoute = true; + routeLostAt = System.currentTimeMillis(); + App.get().handler.removeCallbacks(routeWatch); + App.get().handler.postDelayed(routeWatch, 1000); + } + } + }; + + private final Runnable routeWatch = new Runnable() { + public void run() { + if (!pausedByRoute) return; + long gone = System.currentTimeMillis() - routeLostAt; + if (audio.isBluetoothA2dpOn()) { + com.minamp.net.DiagLog.log("Bluetooth speaker back after " + gone / 1000 + "s: resuming"); + pausedByRoute = false; + play(); + } else if (gone < 20000) { + App.get().handler.postDelayed(this, 1000); + } else { + com.minamp.net.DiagLog.log("Bluetooth speaker didn't come back: staying paused"); + pausedByRoute = false; + } + } }; private void registerNoisy() { From 9440536fa19f339247983fde02090f69dd8fd462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sun, 18 Oct 2026 14:20:22 +0200 Subject: [PATCH 2/3] Recover from playback stalls; cap quality over Bluetooth On the Galaxy Mini, Wi-Fi and Bluetooth audio share one radio: streams starve and the media player then sits at the same position forever while showing playing. After 10 s without progress, recover like a network failure (reconnect and resume, then download first, then skip). New setting: quality over Bluetooth (default 192 kbps) to leave the radio airtime for the speaker. Co-Authored-By: Claude Opus 5.5 --- res/values-en/strings.xml | 4 +++- res/values/strings.xml | 4 +++- src/com/minamp/App.java | 8 ++++++++ src/com/minamp/BaseActivity.java | 24 +++++++++++++----------- src/com/minamp/PlayerService.java | 16 +++++++++++----- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/res/values-en/strings.xml b/res/values-en/strings.xml index b7f4bd7..03051d0 100644 --- a/res/values-en/strings.xml +++ b/res/values-en/strings.xml @@ -199,7 +199,7 @@ Most played Title Year (newest) - Playback is stuck. Try pausing and playing again. + Playback got stuck: the network isn\'t keeping up. Starting %1$s… Stream interrupted Surprise me @@ -236,4 +236,6 @@ Removed from favourites Queue you + Quality over Bluetooth + Quality over Bluetooth: %1$s diff --git a/res/values/strings.xml b/res/values/strings.xml index f8e136e..a99b0b6 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -199,7 +199,7 @@ Máis escoitados Título Ano (máis recentes) - A reprodución quedou parada. Proba a pausar e reproducir de novo. + A reprodución quedou parada: a rede non dá abasto. Iniciando %1$s… Interrompeuse a transmisión Sorpréndeme @@ -236,4 +236,6 @@ Quitada de favoritas Cola ti + Calidade por Bluetooth + Calidade por Bluetooth: %1$s diff --git a/src/com/minamp/App.java b/src/com/minamp/App.java index 6f87f3a..69ec321 100644 --- a/src/com/minamp/App.java +++ b/src/com/minamp/App.java @@ -296,12 +296,20 @@ public class App extends Application { public int qualityHome() { return prefs.getInt("qHome", 0); } public int qualityAway() { return prefs.getInt("qAway", 192); } + /** Cap while the sound goes to a Bluetooth speaker (0 = none). Wi-Fi and Bluetooth share this phone's radio: + * less to download leaves the Bluetooth link the airtime it needs, so it doesn't stutter. */ + public int qualityBluetooth() { return prefs.getInt("qBt", 192); } /** Max kbps for the current situation; 0 = original. */ public int currentMaxKbps() { boolean away = server == null || !server.isLocal() || onMobileData(); int q = away ? qualityAway() : qualityHome(); if (server != null && server.isRelay() && (q == 0 || q > 192)) q = 192; // relay is bandwidth-capped + int bt = qualityBluetooth(); + if (bt > 0 && (q == 0 || q > bt)) { + android.media.AudioManager am = (android.media.AudioManager) getSystemService(AUDIO_SERVICE); + if (am != null && am.isBluetoothA2dpOn()) q = bt; + } return q; } diff --git a/src/com/minamp/BaseActivity.java b/src/com/minamp/BaseActivity.java index 93bc5ef..c864984 100644 --- a/src/com/minamp/BaseActivity.java +++ b/src/com/minamp/BaseActivity.java @@ -435,6 +435,7 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis final String[] rows = { str(R.string.set_quality_home, qualityLabel(app.qualityHome())), str(R.string.set_quality_away, qualityLabel(app.qualityAway())), + str(R.string.set_quality_bt, qualityLabel(app.qualityBluetooth())), str(R.string.set_dl_quality, dlQualityLabel(Downloads.get().quality())), str(R.string.set_delete_downloads, Downloads.size(Downloads.get().bytesOnDisk())), str(R.string.set_download_first, str(app.downloadFirst() ? R.string.on : R.string.off)), @@ -454,21 +455,22 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis switch (which) { case 0: pickQuality("qHome", app.qualityHome(), str(R.string.quality_home_title)); break; case 1: pickQuality("qAway", app.qualityAway(), str(R.string.quality_away_title)); break; - case 2: pickDownloadQuality(); break; - case 3: confirmDeleteDownloads(); break; - case 4: + case 2: pickQuality("qBt", app.qualityBluetooth(), str(R.string.quality_bt_title)); break; + case 3: pickDownloadQuality(); break; + case 4: confirmDeleteDownloads(); break; + case 5: app.prefs.edit().putBoolean("downloadFirst", !app.downloadFirst()).commit(); toast(str(R.string.download_first_toast, str(app.downloadFirst() ? R.string.on : R.string.off))); break; - case 5: + case 6: Server.preferSecureAtHome = !Server.preferSecureAtHome; app.prefs.edit().putBoolean("secureHome", Server.preferSecureAtHome).commit(); if (app.server != null) app.server.invalidate(); toast(str(Server.preferSecureAtHome ? R.string.https_home_on : R.string.https_home_off)); break; - case 6: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break; - case 7: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break; - case 8: + case 7: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break; + case 8: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break; + case 9: if (app.server != null) { app.server.invalidate(); toast(str(R.string.reconnecting)); @@ -480,10 +482,10 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis }); } break; - case 9: showDiagnostics(); break; - case 10: pickLanguage(); break; - case 11: pickSize(); break; - case 12: confirmSignOut(); break; + case 10: showDiagnostics(); break; + case 11: pickLanguage(); break; + case 12: pickSize(); break; + case 13: confirmSignOut(); break; } } }).show(); diff --git a/src/com/minamp/PlayerService.java b/src/com/minamp/PlayerService.java index 70e9991..1895a84 100644 --- a/src/com/minamp/PlayerService.java +++ b/src/com/minamp/PlayerService.java @@ -146,7 +146,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private boolean pausedByCall; private boolean pausedByRoute; // the Bluetooth speaker dropped: resume if it's back soon private long routeLostAt; - private boolean btOn; // audio was going to Bluetooth (A2DP), as last seen while playing + 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 WifiManager.WifiLock wifiLock; private boolean noisyRegistered; private boolean foreground; @@ -489,6 +490,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList } private void trackFailed(String msg) { + failedAtPos = Math.max(currentPosition(), lastPos); com.minamp.net.DiagLog.log("track failed: " + msg + (refusal != null ? " / " + refusal : "") + " (retry " + (retries + 1) + ")"); long pos = Math.max(currentPosition(), lastPos); if (refusal != null) msg = refusal; // the server's own reason beats "Playback error (1/-1004)" @@ -627,15 +629,19 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList if (state == PLAYING && track != null) { long pos = currentPosition(); lastPos = pos; - // "playing" but not moving for 12 s: say so instead of staying silent + // "playing" but not moving: the stream starved (Wi-Fi stalled, often because Bluetooth audio + // shares the radio) and the media player waits forever. Recover like a network error: + // reconnect and resume here; then download the track first; then move on. if (pos != stallPos) { stallPos = pos; stallSince = now; stallWarned = false; - } else if (!stallWarned && now - stallSince > 12000) { + // playing smoothly again for a while: a later stall starts the recovery steps afresh + if (retries > 0 && failedAtPos >= 0 && pos - failedAtPos > 30000) { retries = 0; failedAtPos = -1; } + } else if (!stallWarned && now - stallSince > 10000) { stallWarned = true; - com.minamp.net.DiagLog.log("playback stalled at " + pos / 1000 + "s"); - toast(getString(R.string.stalled)); + com.minamp.net.DiagLog.log("playback stalled at " + pos / 1000 + "s" + (btOn ? " (Bluetooth)" : "") + ": recovering"); + trackFailed(getString(R.string.stalled)); } if (!scrobbled && track.duration > 0 && pos > track.duration * 9 / 10) scrobble(track); if (now - lastTimeline > 10000) report(false); From 430da46544975359d42c062373a05bce23603e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sun, 18 Oct 2026 14:49:45 +0200 Subject: [PATCH 3/3] README: internal install, quality over Bluetooth, stall recovery Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1da53ce..70d72cd 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ log stays in English since it's meant for troubleshooting. 1. On the phone: **Settings → Applications → Unknown sources** (tick it). 2. Copy `Minamp.apk` to the SD card (USB cable, or download it in the browser) and open it with a file manager, or from a computer: `adb install Minamp.apk`. -3. The app can live on the SD card (Settings → Applications → Manage → Minamp → Move to SD card), - which helps with the Mini's tiny internal storage. +3. Minamp installs to internal storage (about 1 MB): an app on the SD card is killed whenever the + phone shares its SD card over USB, which stopped the music. ## Sign in @@ -56,11 +56,16 @@ clock has been reset to before 2024 the certificate date check is skipped rather * MP3, AAC (M4A) and Ogg Vorbis play directly. FLAC, ALAC etc. are transcoded to MP3 by the server (Android 2.2/2.3 can't decode FLAC). * **Menu → Settings**: separate quality for home Wi-Fi (default *Original*) and for away/mobile - data (default *192 kbps MP3*); 320/256/192/128/96 kbps available. + data (default *192 kbps MP3*); 320/256/192/128/96 kbps available. *Quality over Bluetooth* + (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. * If a stream fails, Minamp 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 - mode (useful on flaky mobile data). + mode (useful on flaky mobile data). Playback that stalls ("playing" but the time doesn't move + for 10 s) is recovered the same way. +* A Bluetooth speaker that drops its link for a moment pauses Minamp, which resumes by itself if the + speaker is back within 20 s. Why playback paused or stopped is written to the connection log. * Plays are reported to Plex (Now Playing in the Plex dashboard, play counts, resume position). ## Features