Merge pull request 'Minamp 1.9: playback fixes for Bluetooth and USB' (#2) from ios-backports into main

This commit was merged in pull request #2.
This commit is contained in:
xocko committed 2026-10-18 12:49:56 +00:00
commit 9aaeb57c87
8 files changed
+91 -25

No files matched your search

+3 -3
View File
@@ -1,9 +1,9 @@
<?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="9" android:versionCode="10"
android:versionName="1.8" android:versionName="1.9"
android:installLocation="auto"> android:installLocation="internalOnly">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" /> <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
+9 -4
View File
@@ -15,8 +15,8 @@ log stays in English since it's meant for troubleshooting.
1. On the phone: **Settings → Applications → Unknown sources** (tick it). 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 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`. 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), 3. Minamp installs to internal storage (about 1 MB): an app on the SD card is killed whenever the
which helps with the Mini's tiny internal storage. phone shares its SD card over USB, which stopped the music.
## Sign in ## 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 * 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). server (Android 2.2/2.3 can't decode FLAC).
* **Menu → Settings**: separate quality for home Wi-Fi (default *Original*) and for away/mobile * **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. * 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 * 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 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). * Plays are reported to Plex (Now Playing in the Plex dashboard, play counts, resume position).
## Features ## Features
+3 -1
View File
@@ -199,7 +199,7 @@
<string name="sort_played">Most played</string> <string name="sort_played">Most played</string>
<string name="sort_title">Title</string> <string name="sort_title">Title</string>
<string name="sort_year">Year (newest)</string> <string name="sort_year">Year (newest)</string>
<string name="stalled">Playback is stuck. Try pausing and playing again.</string> <string name="stalled">Playback got stuck: the network isn\'t keeping up.</string>
<string name="starting_x">Starting %1$s…</string> <string name="starting_x">Starting %1$s…</string>
<string name="stream_interrupted">Stream interrupted</string> <string name="stream_interrupted">Stream interrupted</string>
<string name="surprise_me">Surprise me</string> <string name="surprise_me">Surprise me</string>
@@ -236,4 +236,6 @@
<string name="unloved">Removed from favourites</string> <string name="unloved">Removed from favourites</string>
<string name="up_next">Queue</string> <string name="up_next">Queue</string>
<string name="you">you</string> <string name="you">you</string>
<string name="quality_bt_title">Quality over Bluetooth</string>
<string name="set_quality_bt">Quality over Bluetooth: %1$s</string>
</resources> </resources>
+3 -1
View File
@@ -199,7 +199,7 @@
<string name="sort_played">Máis escoitados</string> <string name="sort_played">Máis escoitados</string>
<string name="sort_title">Título</string> <string name="sort_title">Título</string>
<string name="sort_year">Ano (máis recentes)</string> <string name="sort_year">Ano (máis recentes)</string>
<string name="stalled">A reprodución quedou parada. Proba a pausar e reproducir de novo.</string> <string name="stalled">A reprodución quedou parada: a rede non dá abasto.</string>
<string name="starting_x">Iniciando %1$s…</string> <string name="starting_x">Iniciando %1$s…</string>
<string name="stream_interrupted">Interrompeuse a transmisión</string> <string name="stream_interrupted">Interrompeuse a transmisión</string>
<string name="surprise_me">Sorpréndeme</string> <string name="surprise_me">Sorpréndeme</string>
@@ -236,4 +236,6 @@
<string name="unloved">Quitada de favoritas</string> <string name="unloved">Quitada de favoritas</string>
<string name="up_next">Cola</string> <string name="up_next">Cola</string>
<string name="you">ti</string> <string name="you">ti</string>
<string name="quality_bt_title">Calidade por Bluetooth</string>
<string name="set_quality_bt">Calidade por Bluetooth: %1$s</string>
</resources> </resources>
+8
View File
@@ -296,12 +296,20 @@ public class App extends Application {
public int qualityHome() { return prefs.getInt("qHome", 0); } public int qualityHome() { return prefs.getInt("qHome", 0); }
public int qualityAway() { return prefs.getInt("qAway", 192); } 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. */ /** Max kbps for the current situation; 0 = original. */
public int currentMaxKbps() { public int currentMaxKbps() {
boolean away = server == null || !server.isLocal() || onMobileData(); boolean away = server == null || !server.isLocal() || onMobileData();
int q = away ? qualityAway() : qualityHome(); int q = away ? qualityAway() : qualityHome();
if (server != null && server.isRelay() && (q == 0 || q > 192)) q = 192; // relay is bandwidth-capped 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; return q;
} }
+13 -11
View File
@@ -435,6 +435,7 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
final String[] rows = { final String[] rows = {
str(R.string.set_quality_home, qualityLabel(app.qualityHome())), str(R.string.set_quality_home, qualityLabel(app.qualityHome())),
str(R.string.set_quality_away, qualityLabel(app.qualityAway())), 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_dl_quality, dlQualityLabel(Downloads.get().quality())),
str(R.string.set_delete_downloads, Downloads.size(Downloads.get().bytesOnDisk())), 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)), 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) { switch (which) {
case 0: pickQuality("qHome", app.qualityHome(), str(R.string.quality_home_title)); break; 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 1: pickQuality("qAway", app.qualityAway(), str(R.string.quality_away_title)); break;
case 2: pickDownloadQuality(); break; case 2: pickQuality("qBt", app.qualityBluetooth(), str(R.string.quality_bt_title)); break;
case 3: confirmDeleteDownloads(); break; case 3: pickDownloadQuality(); break;
case 4: case 4: confirmDeleteDownloads(); break;
case 5:
app.prefs.edit().putBoolean("downloadFirst", !app.downloadFirst()).commit(); app.prefs.edit().putBoolean("downloadFirst", !app.downloadFirst()).commit();
toast(str(R.string.download_first_toast, str(app.downloadFirst() ? R.string.on : R.string.off))); toast(str(R.string.download_first_toast, str(app.downloadFirst() ? R.string.on : R.string.off)));
break; break;
case 5: case 6:
Server.preferSecureAtHome = !Server.preferSecureAtHome; Server.preferSecureAtHome = !Server.preferSecureAtHome;
app.prefs.edit().putBoolean("secureHome", Server.preferSecureAtHome).commit(); app.prefs.edit().putBoolean("secureHome", Server.preferSecureAtHome).commit();
if (app.server != null) app.server.invalidate(); if (app.server != null) app.server.invalidate();
toast(str(Server.preferSecureAtHome ? R.string.https_home_on : R.string.https_home_off)); toast(str(Server.preferSecureAtHome ? R.string.https_home_on : R.string.https_home_off));
break; 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_LIBRARY)); break;
case 7: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break; case 8: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break;
case 8: case 9:
if (app.server != null) { if (app.server != null) {
app.server.invalidate(); app.server.invalidate();
toast(str(R.string.reconnecting)); toast(str(R.string.reconnecting));
@@ -480,10 +482,10 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
}); });
} }
break; break;
case 9: showDiagnostics(); break; case 10: showDiagnostics(); break;
case 10: pickLanguage(); break; case 11: pickLanguage(); break;
case 11: pickSize(); break; case 12: pickSize(); break;
case 12: confirmSignOut(); break; case 13: confirmSignOut(); break;
} }
} }
}).show(); }).show();
+1
View File
@@ -19,6 +19,7 @@ public class MediaButtonReceiver extends BroadcastReceiver {
if (ev == null) return; if (ev == null) return;
if (isOrderedBroadcast()) abortBroadcast(); if (isOrderedBroadcast()) abortBroadcast();
if (ev.getAction() != KeyEvent.ACTION_DOWN || ev.getRepeatCount() > 0) return; 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(); final Context app = context.getApplicationContext();
switch (ev.getKeyCode()) { switch (ev.getKeyCode()) {
case KeyEvent.KEYCODE_HEADSETHOOK: case KeyEvent.KEYCODE_HEADSETHOOK:
+51 -5
View File
@@ -144,6 +144,10 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private boolean hasFocus; private boolean hasFocus;
private boolean pausedByFocus; private boolean pausedByFocus;
private boolean pausedByCall; private boolean pausedByCall;
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 WifiManager.WifiLock wifiLock; private WifiManager.WifiLock wifiLock;
private boolean noisyRegistered; private boolean noisyRegistered;
private boolean foreground; private boolean foreground;
@@ -486,6 +490,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
} }
private void trackFailed(String msg) { 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); long pos = Math.max(currentPosition(), lastPos);
if (refusal != null) msg = refusal; // the server's own reason beats "Playback error (1/-1004)" 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; boolean permanent = refusalCode >= 400 && refusalCode < 500 && refusalCode != 408 && refusalCode != 429;
@@ -536,6 +542,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private void pause() { private void pause() {
pausedByFocus = false; pausedByFocus = false;
pausedByRoute = false;
playWhenReady = false; playWhenReady = false;
if (mp != null && prepared) { if (mp != null && prepared) {
try { mp.pause(); } catch (Exception ignored) {} try { mp.pause(); } catch (Exception ignored) {}
@@ -611,6 +618,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private final Runnable ticker = new Runnable() { private final Runnable ticker = new Runnable() {
public void run() { public void run() {
if (state == PLAYING) btOn = audio.isBluetoothA2dpOn();
App.get().handler.postDelayed(this, 1000); App.get().handler.postDelayed(this, 1000);
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
if (sleepAt > 0 && now >= sleepAt) { if (sleepAt > 0 && now >= sleepAt) {
@@ -621,15 +629,19 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (state == PLAYING && track != null) { if (state == PLAYING && track != null) {
long pos = currentPosition(); long pos = currentPosition();
lastPos = pos; 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) { if (pos != stallPos) {
stallPos = pos; stallPos = pos;
stallSince = now; stallSince = now;
stallWarned = false; 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; stallWarned = true;
com.minamp.net.DiagLog.log("playback stalled at " + pos / 1000 + "s"); com.minamp.net.DiagLog.log("playback stalled at " + pos / 1000 + "s" + (btOn ? " (Bluetooth)" : "") + ": recovering");
toast(getString(R.string.stalled)); trackFailed(getString(R.string.stalled));
} }
if (!scrobbled && track.duration > 0 && pos > track.duration * 9 / 10) scrobble(track); if (!scrobbled && track.duration > 0 && pos > track.duration * 9 / 10) scrobble(track);
if (now - lastTimeline > 10000) report(false); if (now - lastTimeline > 10000) report(false);
@@ -692,10 +704,12 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (pausedByFocus) { pausedByFocus = false; play(); } if (pausedByFocus) { pausedByFocus = false; play(); }
break; break;
case AudioManager.AUDIOFOCUS_LOSS: case AudioManager.AUDIOFOCUS_LOSS:
com.minamp.net.DiagLog.log("paused: another app took the audio (focus lost)");
hasFocus = false; hasFocus = false;
pause(); pause();
break; break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: 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; } if (state == PLAYING) { pause(); pausedByFocus = true; }
break; break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
@@ -716,8 +730,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() { 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() { private void registerNoisy() {