From f9050bbac045c4d0499b0772dd979411e0ba7d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sat, 17 Oct 2026 16:03:46 +0200 Subject: [PATCH 1/3] Backport from the iOS version: offline downloads, home-first reconnect, stall warning, Close - Downloads for offline listening: long-press a track, album, artist or playlist -> Download; Menu -> Downloads lists them (progress, space used/free, play/shuffle, retry, remove). Saved on the SD card (Android/data/com.minamp/ files/downloads). MP3 originals kept as they are unless above a lower download quality; the rest transcoded to MP3 (320 kbps by default). One at a time, Range resume for originals, retried when the server is back, Wi-Fi lock. - Downloaded tracks play from the SD card, offline too; plays made offline are reported to Plex when a connection works again. - Server.pick: when last time's connection was remote, give the plain home addresses 2 s first (a hairpinned remote address kept us encrypted and capped at home). test/PreferHomeTest.java. - Playback "playing" but not moving for 12 s: say so instead of staying silent. - Menu -> Close Minamp: stop the music and close every screen. Co-Authored-By: Claude Opus 5.5 --- AndroidManifest.xml | 2 + res/values-en/strings.xml | 25 ++ res/values/strings.xml | 25 ++ src/com/minamp/App.java | 39 +++ src/com/minamp/BaseActivity.java | 89 +++++- src/com/minamp/Downloads.java | 435 ++++++++++++++++++++++++++ src/com/minamp/DownloadsActivity.java | 136 ++++++++ src/com/minamp/HomeActivity.java | 1 + src/com/minamp/ItemAdapter.java | 12 +- src/com/minamp/PlayerService.java | 37 ++- src/com/minamp/plex/Server.java | 88 ++++-- test/PreferHomeTest.java | 42 +++ test/identity_server.py | 15 + tools/check_fit.py | 2 + tools/strings.py | 28 ++ 15 files changed, 933 insertions(+), 43 deletions(-) create mode 100644 src/com/minamp/Downloads.java create mode 100644 src/com/minamp/DownloadsActivity.java create mode 100644 test/PreferHomeTest.java create mode 100644 test/identity_server.py diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 310f328..95f6aac 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -50,6 +50,8 @@ android:launchMode="singleTop" /> + diff --git a/res/values-en/strings.xml b/res/values-en/strings.xml index f41a4f1..b7f4bd7 100644 --- a/res/values-en/strings.xml +++ b/res/values-en/strings.xml @@ -7,6 +7,7 @@ Address Album Album › + Already downloaded Minamp Applies from the next track Artist @@ -15,6 +16,7 @@ Back Buffering… Cancel + Cancel pending downloads Couldn\'t list servers: %1$s Couldn\'t load: %1$s (tap to retry) Couldn\'t reach plex.tv: %1$s @@ -34,8 +36,25 @@ Contacting plex.tv…\n(the first secure connection takes a few seconds on this phone) %1$ds radio %1$ds + Delete every downloaded track from this phone? + Delete all downloads + On this phone + Nothing downloaded yet.\nLong-press an album, playlist or track and choose Download. + Failed: %1$s (tap to retry) + In progress + Not enough free space + Downloading %1$d%% + Original MP3s, others 320 kbps + Download quality + Will retry when the server is reachable + %1$d tracks · %2$s · %3$s free + Waiting + Download Download before playing: %1$s + Downloaded Downloading %1$d%% + Downloading %1$s… + Downloads End of track Enter your server\'s address Minamp hit an error last time @@ -108,12 +127,15 @@ Quality at home Original The queue is empty + %1$d tracks added to downloads + Close Minamp Random album Feeling lucky Recently added Recently played Reconnecting… Refresh + Remove download Remove from queue Repeat off Repeat queue @@ -135,6 +157,8 @@ The server wants a token (or sign in with Plex instead) Yours Connection: %1$s + Delete all downloads (%1$s) + Download quality: %1$s Download tracks before playing: %1$s Language: %1$s Library: %1$s @@ -175,6 +199,7 @@ Most played Title Year (newest) + Playback is stuck. Try pausing and playing again. Starting %1$s… Stream interrupted Surprise me diff --git a/res/values/strings.xml b/res/values/strings.xml index fbff888..f8e136e 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -7,6 +7,7 @@ Enderezo Álbum Álbum › + Xa está descargado Minamp Aplícase desde a seguinte pista Artista @@ -15,6 +16,7 @@ Volver Cargando… Cancelar + Cancelar as descargas pendentes Non se puideron listar os servidores: %1$s Non se puido cargar: %1$s (toca para tentar de novo) Non se puido contactar con plex.tv: %1$s @@ -34,8 +36,25 @@ Contactando con plex.tv…\n(a primeira conexión segura tarda uns segundos neste teléfono) Radio da década de %1$d Década de %1$d + Borrar deste teléfono todas as pistas descargadas? + Borrar todas as descargas + Neste teléfono + Aínda non descargaches nada.\nMantén premido un álbum, lista ou pista e escolle Descargar. + Fallou: %1$s (toca para tentar de novo) + En curso + Non hai espazo libre abondo + Descargando %1$d%% + MP3 orixinais, o resto a 320 kbps + Calidade das descargas + Tentarase de novo cando se poida conectar co servidor + %1$d pistas · %2$s · %3$s libres + Agardando + Descargar Descargar antes de reproducir: %1$s + Descargada Descargando %1$d%% + Descargando %1$s… + Descargas Fin da pista Escribe o enderezo do teu servidor Minamp tivo un erro a última vez @@ -108,12 +127,15 @@ Calidade na casa Orixinal A cola está baleira + %1$d pistas engadidas ás descargas + Pechar Minamp Álbum ao chou Proba sorte Novidades Últimas escoitas Reconectando… Actualizar + Borrar a descarga Quitar da cola Sen repetición Repetir a cola @@ -135,6 +157,8 @@ O servidor pide un token (ou inicia sesión con Plex) Teu Conexión: %1$s + Borrar todas as descargas (%1$s) + Calidade das descargas: %1$s Descargar as pistas antes de reproducilas: %1$s Idioma: %1$s Biblioteca: %1$s @@ -175,6 +199,7 @@ Máis escoitados Título Ano (máis recentes) + A reprodución quedou parada. Proba a pausar e reproducir de novo. Iniciando %1$s… Interrompeuse a transmisión Sorpréndeme diff --git a/src/com/minamp/App.java b/src/com/minamp/App.java index 4e86041..6f87f3a 100644 --- a/src/com/minamp/App.java +++ b/src/com/minamp/App.java @@ -22,6 +22,7 @@ import android.os.Handler; import com.minamp.net.CertVerifier; import com.minamp.net.Http; +import com.minamp.plex.Item; import com.minamp.plex.Library; import com.minamp.plex.Plex; import com.minamp.plex.Server; @@ -83,6 +84,8 @@ public class App extends Application { handler.post(new Runnable() { public void run() { if (server == s) prefs.edit().putString("server", s.toJson().toString()).commit(); + flushPendingScrobbles(); + Downloads.get().kick(); // downloads waiting for the server can carry on } }); } @@ -94,6 +97,7 @@ public class App extends Application { @Override public void onReceive(Context c, Intent i) { // Network changed (Wi-Fi <-> mobile, new Wi-Fi...): re-pick the best connection lazily. if (server != null) server.invalidate(); + Downloads.get().kick(); } }, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); } @@ -232,6 +236,41 @@ public class App extends Application { library = new Library(server, key); } + // ------------------------------------------------------------------ offline plays + + /** A play of a downloaded track while the server was unreachable: reported later. */ + public synchronized void addPendingScrobble(String ratingKey) { + String cur = prefs.getString("pendingScrobbles", ""); + String[] parts = cur.length() == 0 ? new String[0] : cur.split(","); + StringBuilder sb = new StringBuilder(); + for (int i = Math.max(0, parts.length - 499); i < parts.length; i++) sb.append(parts[i]).append(','); + sb.append(ratingKey); + prefs.edit().putString("pendingScrobbles", sb.toString()).commit(); + com.minamp.net.DiagLog.log("offline: play of " + ratingKey + " will be reported later"); + } + + /** Sends plays kept while offline (called when a connection has just been found). */ + void flushPendingScrobbles() { + final String cur; + synchronized (this) { + cur = prefs.getString("pendingScrobbles", ""); + if (cur.length() == 0 || library == null) return; + prefs.edit().remove("pendingScrobbles").commit(); + } + final Library lib = library; + com.minamp.net.Threads.start(new Runnable() { + public void run() { + String[] keys = cur.split(","); + for (int i = 0; i < keys.length; i++) { + Item t = new Item(); + t.ratingKey = keys[i]; + lib.scrobble(t); + } + com.minamp.net.DiagLog.log("reported " + keys.length + " offline play(s)"); + } + }, "offline-plays"); + } + public boolean isConfigured() { return server != null && library != null; } public String sectionTitle() { return prefs.getString("sectionTitle", "Music"); } diff --git a/src/com/minamp/BaseActivity.java b/src/com/minamp/BaseActivity.java index 437a5ce..93bc5ef 100644 --- a/src/com/minamp/BaseActivity.java +++ b/src/com/minamp/BaseActivity.java @@ -304,12 +304,15 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis if (it.albumKey.length() > 0) { labels.add(str(R.string.go_to_album)); actions.add(5); } if (it.artistKey.length() > 0) { labels.add(str(R.string.go_to_artist)); actions.add(6); } labels.add(str(it.loved() ? R.string.unlove : R.string.love)); actions.add(7); + if (Downloads.get().entry(it) != null) { labels.add(str(R.string.remove_download)); actions.add(9); } + else { labels.add(str(R.string.download)); actions.add(8); } } else if (it.isContainer()) { labels.add(str(R.string.play)); actions.add(0); labels.add(str(it.type == Item.ARTIST ? R.string.artist_radio_shuffle : R.string.shuffle)); actions.add(3); labels.add(str(R.string.play_next)); actions.add(1); labels.add(str(R.string.add_to_queue)); actions.add(2); if (it.type == Item.ALBUM && it.artistKey.length() > 0) { labels.add(str(R.string.go_to_artist)); actions.add(6); } + labels.add(str(R.string.download)); actions.add(8); } else return; new AlertDialog.Builder(this) .setTitle(it.title) @@ -339,9 +342,33 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis case 5: openKey(it.albumKey, Item.ALBUM, it.album); break; case 6: openKey(it.artistKey, Item.ARTIST, it.type == Item.ALBUM ? it.artist : it.artist); break; case 7: toggleLove(it, null); break; + case 8: download(it); break; + case 9: Downloads.get().remove(it); break; } } + /** Queue a track, or every track of an album/artist/playlist, for offline listening. */ + protected void download(final Item it) { + if (it.type == Item.TRACK) { + List one = new ArrayList(); + one.add(it); + int n = Downloads.get().add(one); + toast(n > 0 ? str(R.string.queued_n, n) : str(R.string.already_downloaded)); + return; + } + final Library lib = lib(); + if (lib == null) return; + toast(str(R.string.downloading_x, it.title)); + run(new Task>() { + public List work() throws Exception { return lib.tracksOf(it); } + public void done(List tracks) { + int n = Downloads.get().add(tracks); + toast(n > 0 ? str(R.string.queued_n, n) : str(R.string.already_downloaded)); + } + public void fail(Exception e) { toast(msg(e)); } + }); + } + protected void openKey(String ratingKey, int type, String title) { Item i = new Item(); i.type = type; @@ -376,7 +403,9 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis public boolean onCreateOptionsMenu(Menu menu) { menu.add(0, 1, 0, R.string.now_playing); menu.add(0, 2, 0, R.string.search); + menu.add(0, 4, 0, R.string.downloads); menu.add(0, 3, 0, R.string.settings); + menu.add(0, 5, 0, R.string.quit_app); return true; } @@ -386,10 +415,19 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis case 1: startActivity(new Intent(this, NowPlayingActivity.class)); return true; case 2: startActivity(new Intent(this, SearchActivity.class)); return true; case 3: showSettings(); return true; + case 4: startActivity(new Intent(this, DownloadsActivity.class)); return true; + case 5: quitApp(); return true; } return super.onOptionsItemSelected(item); } + /** Stops the music and closes every screen (back to the launcher). */ + protected void quitApp() { + PlayerService.send(this, PlayerService.ACTION_STOP); + startActivity(new Intent(this, HomeActivity.class).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP).putExtra("exit", true)); + if (!(this instanceof HomeActivity)) finish(); + } + protected void showSettings() { Server s = app.server; String conn = s == null || s.active() == null ? str(R.string.not_connected_yet) : Server.kindLabel(s.active().kind) @@ -397,6 +435,8 @@ 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_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)), str(R.string.set_secure_home, str(Server.preferSecureAtHome ? R.string.on : R.string.off)), str(R.string.set_library, app.sectionTitle()), @@ -414,19 +454,21 @@ 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: + case 2: pickDownloadQuality(); break; + case 3: confirmDeleteDownloads(); break; + case 4: 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 3: + case 5: 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 4: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break; - case 5: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break; - case 6: + 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: if (app.server != null) { app.server.invalidate(); toast(str(R.string.reconnecting)); @@ -438,10 +480,10 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis }); } break; - case 7: showDiagnostics(); break; - case 8: pickLanguage(); break; - case 9: pickSize(); break; - case 10: confirmSignOut(); break; + case 9: showDiagnostics(); break; + case 10: pickLanguage(); break; + case 11: pickSize(); break; + case 12: confirmSignOut(); break; } } }).show(); @@ -566,6 +608,35 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis }); } + private String dlQualityLabel(int kbps) { + return kbps >= 320 ? str(R.string.dl_quality_original) : kbps + " kbps MP3"; + } + + private void pickDownloadQuality() { + final int[] values = {320, 256, 192, 128}; + final String[] labels = {dlQualityLabel(320), dlQualityLabel(256), dlQualityLabel(192), dlQualityLabel(128)}; + int sel = 0; + for (int i = 0; i < values.length; i++) if (values[i] == Downloads.get().quality()) sel = i; + new AlertDialog.Builder(this) + .setTitle(R.string.dl_quality_title) + .setSingleChoiceItems(labels, sel, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int which) { + Downloads.get().setQuality(values[which]); + d.dismiss(); + } + }).show(); + } + + protected void confirmDeleteDownloads() { + new AlertDialog.Builder(this) + .setMessage(R.string.delete_all_confirm) + .setPositiveButton(R.string.delete_all_downloads, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { Downloads.get().removeAll(); } + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + private String qualityLabel(int kbps) { if (kbps <= 0) return str(R.string.quality_original); for (int i = 0; i < App.QUALITIES.length; i++) if (App.QUALITIES[i] == kbps) return App.QUALITY_LABELS[i]; diff --git a/src/com/minamp/Downloads.java b/src/com/minamp/Downloads.java new file mode 100644 index 0000000..6a3acbd --- /dev/null +++ b/src/com/minamp/Downloads.java @@ -0,0 +1,435 @@ +package com.minamp; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.os.Environment; +import android.os.StatFs; + +import com.minamp.net.DiagLog; +import com.minamp.net.Http; +import com.minamp.net.Threads; +import com.minamp.plex.Item; +import com.minamp.plex.Library; +import com.minamp.util.Json; + +/** + * Tracks saved for offline listening (SD card: Android/data/com.minamp/files/downloads/.mp3). + * MP3 originals are kept as they are, unless above a lower quality chosen to save space; anything + * else is transcoded to MP3 by the server. One track at a time; interrupted originals resume with + * Range, everything waits and retries when the server is unreachable. Lists are read and changed + * on the main thread only. + */ +public final class Downloads { + + public static final int QUEUED = 0, DOWNLOADING = 1, DONE = 2, FAILED = 3; + private static final long MIN_FREE = 100L * 1024 * 1024; + private static final int MAX_TRIES = 5; + + public static final class Entry { + public Item item; + public int state; + public long bytes, total = -1; + public String error; + public int tries; + } + + public interface Listener { void onDownloadsChanged(); } + + private static Downloads instance; + + public static synchronized Downloads get() { + if (instance == null) instance = new Downloads(App.get()); + return instance; + } + + private final App app; + private final List list = new ArrayList(); + private final Map byKey = new HashMap(); + private final List listeners = new ArrayList(); + private Entry active; + private volatile Http.Response resp; + private volatile boolean cancel; + private long waitUntil; + private long lastNotify; + private WifiManager.WifiLock wifiLock; + + private Downloads(App app) { + this.app = app; + load(); + app.handler.postDelayed(new Runnable() { public void run() { pump(); } }, 3000); + } + + // ------------------------------------------------------------------ storage + + /** On the SD card when there is one (the Galaxy Mini's internal storage is tiny). */ + private File dir() { + File d = null; + try { + if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) d = app.getExternalFilesDir("downloads"); + } catch (Exception ignored) {} + if (d == null) d = new File(app.getFilesDir(), "downloads"); + if (!d.exists()) d.mkdirs(); + return d; + } + + private File fileOf(Item t, boolean part) { return new File(dir(), t.ratingKey + (part ? ".mp3.part" : ".mp3")); } + + private File indexFile() { return new File(app.getFilesDir(), "downloads.json"); } + + private void load() { + try { + File f = indexFile(); + if (!f.exists()) return; + Json.Arr a = (Json.Arr) Json.parse(new String(App.readAll(new FileInputStream(f)), "UTF-8")); + for (int i = 0; i < a.size(); i++) { + Json.Obj o = a.optObj(i); + if (o == null || o.optObj("item") == null) continue; + Entry e = new Entry(); + e.item = Item.fromJson(o.optObj("item")); + if (e.item.ratingKey.length() == 0 || byKey.containsKey(e.item.ratingKey)) continue; + e.state = o.optInt("state", QUEUED); + if (e.state == DOWNLOADING) e.state = QUEUED; + e.bytes = o.optLong("bytes", 0); + e.total = o.optLong("total", -1); + e.tries = o.optInt("tries", 0); + String err = o.optString("error", ""); + e.error = err.length() > 0 ? err : null; + if (e.state == DONE && !fileOf(e.item, false).exists()) continue; // SD card swapped or files deleted + list.add(e); + byKey.put(e.item.ratingKey, e); + } + } catch (Exception e) { + DiagLog.log("downloads index unreadable: " + Threads.describe(e)); + } + } + + private void save() { + Json.Arr a = new Json.Arr(); + for (int i = 0; i < list.size(); i++) { + Entry e = list.get(i); + a.add(new Json.Obj().put("item", e.item.toJson()).put("state", Long.valueOf(e.state)) + .put("bytes", Long.valueOf(e.bytes)).put("total", Long.valueOf(e.total)) + .put("tries", Long.valueOf(e.tries)).put("error", e.error == null ? "" : e.error)); + } + final String data = a.toString(); + Threads.start(new Runnable() { + public void run() { + synchronized (Downloads.class) { + try { + File tmp = new File(app.getFilesDir(), "downloads.tmp"); + FileOutputStream out = new FileOutputStream(tmp); + out.write(data.getBytes("UTF-8")); + out.close(); + tmp.renameTo(indexFile()); + } catch (Exception ignored) {} + } + } + }, "save-downloads"); + } + + // ------------------------------------------------------------------ listeners + + public void addListener(Listener l) { if (!listeners.contains(l)) listeners.add(l); } + public void removeListener(Listener l) { listeners.remove(l); } + + private void changed(boolean force) { + long now = System.currentTimeMillis(); + if (!force && now - lastNotify < 500) return; + lastNotify = now; + for (int i = listeners.size() - 1; i >= 0; i--) if (i < listeners.size()) listeners.get(i).onDownloadsChanged(); + } + + // ------------------------------------------------------------------ queries + + public Entry entry(Item t) { return t == null ? null : byKey.get(t.ratingKey); } + + /** The saved file of a finished download, or null. */ + public File fileFor(Item t) { + Entry e = entry(t); + return e != null && e.state == DONE ? fileOf(e.item, false) : null; + } + + public boolean isDownloaded(Item t) { return fileFor(t) != null; } + + public List pending() { + List out = new ArrayList(); + for (int i = 0; i < list.size(); i++) if (list.get(i).state != DONE) out.add(list.get(i)); + return out; + } + + /** Finished downloads, by artist, album and track number. */ + public List done() { + List out = new ArrayList(); + for (int i = 0; i < list.size(); i++) if (list.get(i).state == DONE) out.add(list.get(i)); + Collections.sort(out, new Comparator() { + public int compare(Entry x, Entry y) { + int r = x.item.artist.compareToIgnoreCase(y.item.artist); + if (r == 0) r = x.item.album.compareToIgnoreCase(y.item.album); + if (r == 0) r = x.item.index - y.item.index; + return r; + } + }); + return out; + } + + public List doneTracks() { + List d = done(); + List out = new ArrayList(); + for (int i = 0; i < d.size(); i++) out.add(d.get(i).item); + return out; + } + + public long bytesOnDisk() { + long n = 0; + for (int i = 0; i < list.size(); i++) if (list.get(i).state == DONE) n += list.get(i).bytes; + return n; + } + + public long freeSpace() { + try { + StatFs s = new StatFs(dir().getPath()); + return (long) s.getAvailableBlocks() * s.getBlockSize(); + } catch (Exception e) { + return -1; + } + } + + public static String size(long b) { + if (b >= 1024L * 1024 * 1024) return String.format(java.util.Locale.US, "%.1f GB", b / (1024.0 * 1024 * 1024)); + if (b >= 1024 * 1024) return (b / (1024 * 1024)) + " MB"; + return (b / 1024) + " KB"; + } + + /** 320 (default): MP3 originals as they are, the rest at 320 kbps. Lower: everything above it shrunk. */ + public int quality() { return app.prefs.getInt("dlQuality", 320); } + + public void setQuality(int kbps) { app.prefs.edit().putInt("dlQuality", kbps).commit(); } + + // ------------------------------------------------------------------ changes + + /** Queues tracks; returns how many were new (failed ones are retried). */ + public int add(List tracks) { + int added = 0; + for (int i = 0; i < tracks.size(); i++) { + Item t = tracks.get(i); + if (t.type != Item.TRACK || t.ratingKey.length() == 0) continue; + Entry e = byKey.get(t.ratingKey); + if (e != null) { + if (e.state == FAILED) { e.state = QUEUED; e.tries = 0; e.error = null; added++; } + continue; + } + e = new Entry(); + e.item = t; + list.add(e); + byKey.put(t.ratingKey, e); + added++; + } + save(); + changed(true); + kick(); + return added; + } + + public void remove(Item t) { + Entry e = entry(t); + if (e == null) return; + if (e == active) { + cancel = true; + Http.abort(resp); + } else { + fileOf(e.item, false).delete(); + fileOf(e.item, true).delete(); + } + list.remove(e); + byKey.remove(e.item.ratingKey); + save(); + changed(true); + } + + public void removeAll() { + List all = new ArrayList(list); + for (int i = 0; i < all.size(); i++) remove(all.get(i).item); + } + + public void cancelPending() { + List p = pending(); + for (int i = 0; i < p.size(); i++) remove(p.get(i).item); + } + + public void retry(Item t) { + Entry e = entry(t); + if (e == null || e.state != FAILED) return; + e.state = QUEUED; + e.tries = 0; + e.error = null; + save(); + changed(true); + kick(); + } + + // ------------------------------------------------------------------ worker + + /** Network or connection changed: resume waiting downloads. */ + public void kick() { + waitUntil = 0; + pump(); + } + + private void pump() { + if (active != null || System.currentTimeMillis() < waitUntil) return; + Entry next = null; + for (int i = 0; i < list.size(); i++) if (list.get(i).state == QUEUED) { next = list.get(i); break; } + final Library lib = app.library; + if (next == null || lib == null) { releaseWifi(); return; } + if (wifiLock == null) { + wifiLock = ((WifiManager) app.getSystemService(Context.WIFI_SERVICE)).createWifiLock(WifiManager.WIFI_MODE_FULL, "minamp-dl"); + wifiLock.setReferenceCounted(false); + } + if (!wifiLock.isHeld()) wifiLock.acquire(); + active = next; + active.state = DOWNLOADING; + active.error = null; + cancel = false; + changed(true); + final Entry e = active; + final int quality = quality(); + Threads.start(new Runnable() { + public void run() { + final boolean[] permanent = new boolean[1]; + String err; + try { + err = fetch(e, lib, quality, permanent); + } catch (Throwable t) { + err = Threads.describe(t); + } + final String error = err; + app.handler.post(new Runnable() { public void run() { finished(e, error, permanent[0]); } }); + } + }, "download"); + } + + private void releaseWifi() { + if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); + } + + /** Background thread. Returns null on success, otherwise why not. */ + private String fetch(final Entry e, Library lib, int quality, boolean[] permanent) throws IOException { + Item t = e.item; + try { + lib.ensureMedia(t); + } catch (com.minamp.plex.Plex.PlexException pe) { + permanent[0] = pe.code >= 400 && pe.code < 500; + return pe.getMessage(); + } + long free = freeSpace(); + if (free >= 0 && free < MIN_FREE) { permanent[0] = true; return app.getString(R.string.dl_no_space); } + + boolean direct = Library.canDirectPlay(t, quality >= 320 ? 0 : quality); + File part = fileOf(t, true), fin = fileOf(t, false); + long have = direct && part.exists() ? part.length() : 0; + if (!direct) part.delete(); // a transcode can't be resumed + String session = direct ? null : "minamp-dl-" + Long.toString(System.currentTimeMillis(), 36); + List urls; + if (direct) { urls = new ArrayList(); urls.add(lib.directUrl(t)); } + else urls = lib.transcodeUrls(t, quality > 0 ? quality : 320, 0, session); + int[] order = direct ? null : Library.variantOrder(); + + Map h = lib.server.mediaHeaders(); + if (have > 0) h.put("Range", "bytes=" + have + "-"); + Http.Response r = null; + int variant = -1; + String why = null; + for (int i = 0; i < urls.size(); i++) { + if (cancel) return "cancelled"; + Http.Response up = Http.get(urls.get(i), h, 30000); // network errors throw: retried later + if (up.code < 400 || up.code == 416) { r = up; variant = i; break; } + String body = ""; + try { body = up.readString(); } catch (IOException ignored) {} + why = app.getString(R.string.tr_refused, up.code) + (com.minamp.plex.Plex.snippet(body).length() > 0 ? ": " + com.minamp.plex.Plex.snippet(body) : ""); + permanent[0] = up.code < 500; + } + if (r == null) return why; + permanent[0] = false; + + if (r.code != 416) { + if (r.code == 200) have = 0; // the server ignored the Range: start over + resp = r; + long expected = r.contentLength >= 0 ? have + r.contentLength : -1; + long got = have; + long started = System.currentTimeMillis(), lastUi = 0; + FileOutputStream out = new FileOutputStream(part, have > 0); + boolean ok = true; + try { + byte[] buf = new byte[16384]; + int n; + while ((n = r.body.read(buf)) > 0) { + if (cancel) { ok = false; break; } + out.write(buf, 0, n); + got += n; + long now = System.currentTimeMillis(); + if (now - lastUi > 500) { + lastUi = now; + final long g = got, tot = expected; + app.handler.post(new Runnable() { public void run() { e.bytes = g; if (tot > 0) e.total = tot; changed(false); } }); + } + } + } catch (IOException io) { + ok = false; + } finally { + out.close(); + Http.abort(r); + resp = null; + } + if (session != null) lib.stopTranscode(Library.variantSession(session, order[variant])); + if (cancel) return "cancelled"; + if (!ok || (expected >= 0 && got != expected)) return app.getString(R.string.stream_interrupted); + DiagLog.log("downloaded " + t.title + " (" + size(got) + ", " + (direct ? "original" : "transcoded") + ") in " + + (System.currentTimeMillis() - started) / 1000 + "s"); + } else { + Http.abort(r); + } + fin.delete(); + if (!part.renameTo(fin)) return "can't save " + fin.getName(); + final long size = fin.length(); + app.handler.post(new Runnable() { public void run() { e.bytes = size; e.total = size; } }); + return null; + } + + private void finished(Entry e, String err, boolean permanent) { + if (byKey.get(e.item.ratingKey) != e) { // removed meanwhile + fileOf(e.item, false).delete(); + fileOf(e.item, true).delete(); + } else if (err == null) { + e.state = DONE; + e.error = null; + } else if ("cancelled".equals(err)) { + e.state = QUEUED; + } else if (permanent || ++e.tries >= MAX_TRIES) { + e.state = FAILED; + e.error = err; + DiagLog.log("download failed: " + e.item.title + ": " + err); + } else { + // probably the network: wait a bit (or for a connection change) and carry on + e.state = QUEUED; + e.error = app.getString(R.string.dl_retrying); + DiagLog.log("download interrupted (" + err + "), will retry: " + e.item.title); + waitUntil = System.currentTimeMillis() + 30000; + app.handler.postDelayed(new Runnable() { public void run() { pump(); } }, 31000); + } + active = null; + save(); + changed(true); + pump(); + } +} diff --git a/src/com/minamp/DownloadsActivity.java b/src/com/minamp/DownloadsActivity.java new file mode 100644 index 0000000..995e7c1 --- /dev/null +++ b/src/com/minamp/DownloadsActivity.java @@ -0,0 +1,136 @@ +package com.minamp; + +import java.util.ArrayList; +import java.util.List; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ListView; + +import com.minamp.plex.Item; + +/** Tracks saved on the phone (they play offline) and downloads still in progress. */ +public class DownloadsActivity extends BaseActivity implements Downloads.Listener { + + private ItemAdapter adapter; + private final List pending = new ArrayList(); + + @Override + protected void onCreate(Bundle b) { + super.onCreate(b); + setContentView(R.layout.activity_queue); + setupTopBar(str(R.string.downloads), "", true); + // one bar action only: "Downloads" needs the room at the largest text size + barAction(R.id.btn_action2, Glyph.MORE, new View.OnClickListener() { + public void onClick(View v) { moreMenu(); } + }); + ListView list = (ListView) findViewById(R.id.list); + adapter = new ItemAdapter(this, ItemAdapter.MODE_DEFAULT); + adapter.setDecorator(new ItemAdapter.Decorator() { + public String sub(Item it) { return status(it); } + }); + list.setAdapter(adapter); + list.setOnItemClickListener(new AdapterView.OnItemClickListener() { + public void onItemClick(AdapterView p, View v, int pos, long id) { + Item it = adapter.getItem(pos); + Downloads.Entry e = Downloads.get().entry(it); + if (e == null) return; + if (e.state == Downloads.FAILED) { Downloads.get().retry(it); return; } + if (e.state != Downloads.DONE) return; + List done = Downloads.get().doneTracks(); + int start = 0; + for (int i = 0; i < done.size(); i++) if (done.get(i).ratingKey.equals(it.ratingKey)) start = i; + playAll(false, start); + } + }); + list.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { + public boolean onItemLongClick(AdapterView p, View v, int pos, long id) { + final Item it = adapter.getItem(pos); + if (it.type != Item.TRACK) return false; + new AlertDialog.Builder(DownloadsActivity.this).setTitle(it.title) + .setItems(new String[] {str(R.string.play_next), str(R.string.add_to_queue), str(R.string.remove_download)}, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + if (w == 2) Downloads.get().remove(it); + else itemAction(it, w + 1); + } + }).show(); + return true; + } + }); + } + + @Override + protected void onResume() { + super.onResume(); + Downloads.get().addListener(this); + refresh(); + } + + @Override + protected void onPause() { + super.onPause(); + Downloads.get().removeListener(this); + } + + public void onDownloadsChanged() { refresh(); } + + private String status(Item it) { + Downloads.Entry e = Downloads.get().entry(it); + if (e == null || e.state == Downloads.DONE) return null; + if (e.state == Downloads.FAILED) return str(R.string.dl_failed, e.error == null ? "?" : e.error); + if (e.state == Downloads.DOWNLOADING) { + long total = e.total > 0 ? e.total : it.duration / 1000 * 40000; + int pct = total > 0 ? (int) Math.min(99, e.bytes * 100 / total) : 0; + return str(R.string.dl_progress, pct); + } + return e.error != null ? e.error : str(R.string.dl_waiting); + } + + private void refresh() { + Downloads dl = Downloads.get(); + List rows = new ArrayList(); + pending.clear(); + pending.addAll(dl.pending()); + if (!pending.isEmpty()) { + rows.add(header(str(R.string.dl_in_progress))); + for (int i = 0; i < pending.size(); i++) rows.add(pending.get(i).item); + } + List done = dl.doneTracks(); + if (!done.isEmpty()) { + rows.add(header(str(R.string.dl_done_section))); + rows.addAll(done); + } + adapter.set(rows, -1); + long free = dl.freeSpace(); + setBarSub(str(R.string.dl_summary, done.size(), Downloads.size(dl.bytesOnDisk()), free >= 0 ? Downloads.size(free) : "?")); + if (rows.isEmpty()) showStatus(false, str(R.string.dl_empty), null); else hideStatus(); + } + + private static Item header(String title) { + Item h = new Item(); + h.type = Item.HEADER; + h.title = title; + return h; + } + + private void playAll(boolean shuffle, int start) { + playList(Downloads.get().doneTracks(), shuffle ? -1 : start, shuffle, str(R.string.downloads)); + } + + private void moreMenu() { + new AlertDialog.Builder(this) + .setItems(new String[] {str(R.string.play), str(R.string.shuffle), str(R.string.cancel_downloads), str(R.string.delete_all_downloads)}, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + if (w == 0) playAll(false, 0); + else if (w == 1) playAll(true, 0); + else if (w == 2) Downloads.get().cancelPending(); + else confirmDeleteDownloads(); + } + }).show(); + } +} diff --git a/src/com/minamp/HomeActivity.java b/src/com/minamp/HomeActivity.java index 05a9144..66826d8 100644 --- a/src/com/minamp/HomeActivity.java +++ b/src/com/minamp/HomeActivity.java @@ -90,6 +90,7 @@ public class HomeActivity extends BaseActivity { @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); + if (intent.getBooleanExtra("exit", false)) { finish(); return; } // "Close Minamp" if (intent.getBooleanExtra("relaunch", false)) { // language changed: rebuild from scratch so every inflated text is re-read finish(); diff --git a/src/com/minamp/ItemAdapter.java b/src/com/minamp/ItemAdapter.java index 1644711..eb1258e 100644 --- a/src/com/minamp/ItemAdapter.java +++ b/src/com/minamp/ItemAdapter.java @@ -20,15 +20,19 @@ public class ItemAdapter extends BaseAdapter { public interface Pager { void loadMore(int from); } + /** Replaces a row's second line (download progress, errors); null keeps the usual one. */ + public interface Decorator { String sub(Item it); } + private final Context ctx; private final LayoutInflater inflater; private final List items = new ArrayList(); private final int mode; private final int artPx; - private final int accent, text; + private final int accent, text, sub2; private int total = -1; // total available on the server, -1 = everything loaded private boolean loadingMore; private Pager pager; + private Decorator decorator; private String albumArtist = ""; private int highlight = -1; // queue index highlight private final App app; @@ -40,10 +44,12 @@ public class ItemAdapter extends BaseAdapter { artPx = Ui.dimen(c, R.dimen.row_art); accent = c.getResources().getColor(R.color.accent); text = c.getResources().getColor(R.color.text); + sub2 = c.getResources().getColor(R.color.text2); app = App.get(); } public void setPager(Pager p) { pager = p; } + public void setDecorator(Decorator d) { decorator = d; } public void setAlbumArtist(String a) { albumArtist = a == null ? "" : a; } public void setHighlight(int i) { highlight = i; notifyDataSetChanged(); } @@ -132,7 +138,7 @@ public class ItemAdapter extends BaseAdapter { h.art.setVisibility(View.VISIBLE); h.num.setVisibility(View.GONE); app.images.load(h.art, it.thumb, artPx, R.drawable.art_placeholder); - String sub = it.subtitle(); + String sub = decorator != null && decorator.sub(it) != null ? decorator.sub(it) : it.subtitle(); h.sub.setVisibility(sub.length() > 0 ? View.VISIBLE : View.GONE); h.sub.setText(sub); } @@ -140,6 +146,8 @@ public class ItemAdapter extends BaseAdapter { String right = ""; if (it.type == Item.TRACK && it.duration > 0) right = Ui.time(it.duration); h.right.setText(right); + // downloaded tracks: the duration in the accent colour + h.right.setTextColor(it.type == Item.TRACK && Downloads.get().isDownloaded(it) ? accent : sub2); boolean current; if (mode == MODE_QUEUE) current = pos == highlight; diff --git a/src/com/minamp/PlayerService.java b/src/com/minamp/PlayerService.java index 3d7bb48..f4c7ac5 100644 --- a/src/com/minamp/PlayerService.java +++ b/src/com/minamp/PlayerService.java @@ -89,6 +89,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList public static String sourceLabel() { PlayerService s = instance; if (s == null || s.track == null) return ""; + if (s.localFile) return s.getString(R.string.downloaded); App app = App.get(); String where = app.server == null || app.server.active() == null ? "" : com.minamp.plex.Server.kindLabel(app.server.active().kind); String q = s.transcoding ? s.kbps + " kbps MP3" : (s.track.codec.toUpperCase() + (s.track.bitrate > 0 ? " " + s.track.bitrate + " kbps" : "")); @@ -134,6 +135,10 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList private long lastTimeline; private long lastSave; private long lastPos; // last position seen while playing (getCurrentPosition is unreliable after completion) + private boolean localFile; // playing a download from the SD card + private long stallPos = -1; // position last seen moving + private long stallSince; + private boolean stallWarned; private AudioManager audio; private boolean hasFocus; @@ -265,6 +270,17 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList setState(BUFFERING, getString(R.string.connecting)); goForeground(); if (play) requestFocus(); + // Downloaded: play the file on the SD card, no network needed (offline too) + final File saved = Downloads.get().fileFor(it); + localFile = saved != null; + if (saved != null) { + transcoding = false; + session = null; + variantOrder = null; + kbps = 0; + begin(new java.util.ArrayList(), "mp3", saved, 0, posMs); + return; + } final boolean download = forceDownload || app.downloadFirst(); worker.execute(new Runnable() { public void run() { @@ -605,6 +621,16 @@ 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 + if (pos != stallPos) { + stallPos = pos; + stallSince = now; + stallWarned = false; + } else if (!stallWarned && now - stallSince > 12000) { + stallWarned = true; + com.minamp.net.DiagLog.log("playback stalled at " + pos / 1000 + "s"); + toast(getString(R.string.stalled)); + } if (!scrobbled && track.duration > 0 && pos > track.duration * 9 / 10) scrobble(track); if (now - lastTimeline > 10000) report(false); if (now - lastSave > 15000) { lastSave = now; PlayQueue.get().resumePositionMs = pos; } @@ -631,7 +657,16 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList scrobbled = true; final App app = App.get(); reporter.execute(new Runnable() { - public void run() { if (app.library != null) app.library.scrobble(it); } + public void run() { + if (app.library == null) return; + // offline (a download): keep the play and report it when the server is back + boolean online = app.library.server.active() != null; + if (!online) { + try { app.library.server.base(); online = true; } catch (Exception ignored) {} + } + if (online) app.library.scrobble(it); + else app.addPendingScrobble(it.ratingKey); + } }); } diff --git a/src/com/minamp/plex/Server.java b/src/com/minamp/plex/Server.java index c902c47..22edf36 100644 --- a/src/com/minamp/plex/Server.java +++ b/src/com/minamp/plex/Server.java @@ -134,10 +134,14 @@ public final class Server { public volatile String lastGoodUri = ""; private String probe(Conn c) { + boolean lan = c.kind == LOCAL || c.kind == LOCAL_HTTP || (c.kind == MANUAL && isPrivateAddress(c.uri)); + return probe(c, lan ? 5000 : 15000); + } + + private String probe(Conn c, int timeoutMs) { long t0 = System.currentTimeMillis(); try { - boolean lan = c.kind == LOCAL || c.kind == LOCAL_HTTP || (c.kind == MANUAL && isPrivateAddress(c.uri)); - Json.Obj o = Plex.fetchJson("GET", c.uri + "/identity", token, lan ? 5000 : 15000); + Json.Obj o = Plex.fetchJson("GET", c.uri + "/identity", token, timeoutMs); Json.Obj mc = o.optObj("MediaContainer"); String id = mc == null ? "" : mc.optString("machineIdentifier"); if (machineId.length() > 0 && !machineId.equals(id)) return "a different server answered"; @@ -164,6 +168,39 @@ public final class Server { return c; } + /** Probes conns in parallel; the first that answers wins (null if none within waitMs). */ + private Conn firstOf(final List conns, final int timeoutMs, long waitMs, String[] firstError) { + final String[] errs = new String[conns.size()]; + final int[] state = new int[conns.size()]; + final Object lock = new Object(); + for (int i = 0; i < conns.size(); i++) { + final int idx = i; + com.minamp.net.Threads.start(new Runnable() { + public void run() { + String e = probe(conns.get(idx), timeoutMs); + synchronized (lock) { errs[idx] = e; state[idx] = e == null ? 1 : 2; lock.notifyAll(); } + } + }, "probe-" + i); + } + long deadline = System.currentTimeMillis() + waitMs; + synchronized (lock) { + while (true) { + boolean pending = false; + for (int i = 0; i < conns.size(); i++) { + if (state[i] == 1) return conns.get(i); + if (state[i] == 0) pending = true; + } + long left = deadline - System.currentTimeMillis(); + if (!pending || left <= 0) break; + try { lock.wait(left); } catch (InterruptedException e) { break; } + } + for (int i = 0; i < conns.size(); i++) { + if (errs[i] != null && firstError[0] == null) firstError[0] = kindLabel(conns.get(i).kind) + ": " + errs[i]; + } + } + return null; + } + /** * Finds a working connection, cheapest first, never running more than one TLS handshake at a * time (parallel handshakes on a single slow core make every one of them time out): @@ -184,6 +221,19 @@ public final class Server { String firstError = null; List tried = new ArrayList(); + // 0. Last time we were away: if we're home again, the home address beats the remote one + // (which often still works from inside, through the router, but encrypted and capped). + Conn last = null; + for (int i = 0; i < order.size(); i++) if (order.get(i).uri.equals(lastGoodUri)) last = order.get(i); + if (last != null && !isPlainLan(last) && last.kind != LOCAL && !preferSecureAtHome) { + List home = new ArrayList(); + for (int i = 0; i < order.size(); i++) if (isPlainLan(order.get(i))) home.add(order.get(i)); + if (!home.isEmpty()) { + Conn w = firstOf(home, 2000, 2500, new String[1]); + if (w != null) return choose(w); + } + } + // 1. last known good for (int i = 0; i < order.size(); i++) { Conn c = order.get(i); @@ -202,35 +252,11 @@ public final class Server { for (int i = 0; i < order.size(); i++) if (isPlainLan(order.get(i)) && !tried.contains(order.get(i))) plain.add(order.get(i)); } if (!plain.isEmpty()) { - final String[] errs = new String[plain.size()]; - final int[] state = new int[plain.size()]; - final Object lock = new Object(); - for (int i = 0; i < plain.size(); i++) { - final int idx = i; - com.minamp.net.Threads.start(new Runnable() { - public void run() { - String e = probe(plain.get(idx)); - synchronized (lock) { errs[idx] = e; state[idx] = e == null ? 1 : 2; lock.notifyAll(); } - } - }, "probe-" + i); - } - long deadline = System.currentTimeMillis() + 7000; - synchronized (lock) { - while (true) { - boolean pending = false; - for (int i = 0; i < plain.size(); i++) { - if (state[i] == 1) return choose(plain.get(i)); - if (state[i] == 0) pending = true; - } - long left = deadline - System.currentTimeMillis(); - if (!pending || left <= 0) break; - try { lock.wait(left); } catch (InterruptedException e) { break; } - } - } - for (int i = 0; i < plain.size(); i++) { - tried.add(plain.get(i)); - if (firstError == null && errs[i] != null) firstError = kindLabel(plain.get(i).kind) + ": " + errs[i]; - } + String[] err = new String[1]; + Conn w = firstOf(plain, 5000, 7000, err); + if (w != null) return choose(w); + tried.addAll(plain); + if (firstError == null) firstError = err[0]; } // 3. everything else, one at a time, in preference order diff --git a/test/PreferHomeTest.java b/test/PreferHomeTest.java new file mode 100644 index 0000000..816e327 --- /dev/null +++ b/test/PreferHomeTest.java @@ -0,0 +1,42 @@ +import com.minamp.plex.Server; + +/** Server.pick() after being away: the home address wins again when it answers, quickly. */ +public class PreferHomeTest { + static int failures = 0; + + static void check(boolean ok, String what) { + System.out.println((ok ? "PASS " : "FAIL ") + what); + if (!ok) failures++; + } + + static Server server(String home, String remote) { + Server s = new Server(); + s.machineId = "abc123"; + s.add(home, Server.LOCAL_HTTP); + s.add(remote, Server.REMOTE); + s.sortConnections(); + s.lastGoodUri = remote; // last time we were away + return s; + } + + public static void main(String[] a) throws Exception { + String home = "http://127.0.0.1:" + a[0], remote = "http://127.0.0.1:" + a[1], dead = "http://127.0.0.1:1"; + + long t0 = System.currentTimeMillis(); + Server.Conn c = server(home, remote).pick(); + check(c.kind == Server.LOCAL_HTTP, "back home: home address chosen over last time's remote one (" + (System.currentTimeMillis() - t0) + " ms)"); + + t0 = System.currentTimeMillis(); + c = server(dead, remote).pick(); + long ms = System.currentTimeMillis() - t0; + check(c.kind == Server.REMOTE && ms < 3500, "still away: remote chosen, home check cost " + ms + " ms"); + + Server s = server(home, remote); + s.lastGoodUri = home; + c = s.pick(); + check(c.kind == Server.LOCAL_HTTP, "home last time: home again"); + + System.out.println(failures == 0 ? "ALL PASSED" : failures + " FAILED"); + System.exit(failures == 0 ? 0 : 1); + } +} diff --git a/test/identity_server.py b/test/identity_server.py new file mode 100644 index 0000000..c6cae85 --- /dev/null +++ b/test/identity_server.py @@ -0,0 +1,15 @@ +"""Answers /identity like a Plex Media Server (for PreferHomeTest). Usage: identity_server.py PORT""" +import json, sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): pass + def do_GET(self): + body = json.dumps({"MediaContainer": {"machineIdentifier": "abc123"}}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + +ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever() diff --git a/tools/check_fit.py b/tools/check_fit.py index 7194772..c39a2da 100644 --- a/tools/check_fit.py +++ b/tools/check_fit.py @@ -88,6 +88,8 @@ def slots(D): ("pin status", ["pin_not_yet"], D["text_sub"], False, lambda W: W - 40 - 24, 3, ("12", "11")), ("pin instructions", ["pin_open"], D["text_sub"], False, lambda W: W - 40, 2, ()), ("queue title", ["up_next"], D["text_title"], True, lambda W: W - 3 * D["touch"] - 12, 1, ()), + ("downloads title (back + one action)", ["downloads"], D["text_title"], True, lambda W: W - 2 * D["touch"] - 12, 1, ()), + ("downloads empty", ["dl_empty"], D["text_status"], False, lambda W: W - 48, 5, ()), ("search hint", ["search_hint"], D["text_body"], False, lambda W: W - D["touch"] - 12 - 20, 1, ()), ("status message", ["queue_empty", "no_playlists", "pick_something", "nothing_playing"], D["text_status"], False, lambda W: W - 48, 2, ()), diff --git a/tools/strings.py b/tools/strings.py index fec241b..36b1419 100644 --- a/tools/strings.py +++ b/tools/strings.py @@ -230,6 +230,34 @@ S = { "searching": ("Searching…", "Buscando…"), "nothing_found_q": ("Nothing found for “%1$s”", "Non se atopou nada para “%1$s”"), + # downloads (offline listening) + "downloads": ("Downloads", "Descargas"), + "download": ("Download", "Descargar"), + "remove_download": ("Remove download", "Borrar a descarga"), + "downloading_x": ("Downloading %1$s…", "Descargando %1$s…"), + "queued_n": ("%1$d tracks added to downloads", "%1$d pistas engadidas ás descargas"), + "already_downloaded": ("Already downloaded", "Xa está descargado"), + "dl_waiting": ("Waiting", "Agardando"), + "dl_progress": ("Downloading %1$d%%", "Descargando %1$d%%"), + "dl_failed": ("Failed: %1$s (tap to retry)", "Fallou: %1$s (toca para tentar de novo)"), + "dl_retrying": ("Will retry when the server is reachable", "Tentarase de novo cando se poida conectar co servidor"), + "dl_summary": ("%1$d tracks · %2$s · %3$s free", "%1$d pistas · %2$s · %3$s libres"), + "dl_empty": ("Nothing downloaded yet.\nLong-press an album, playlist or track and choose Download.", + "Aínda non descargaches nada.\nMantén premido un álbum, lista ou pista e escolle Descargar."), + "dl_in_progress": ("In progress", "En curso"), + "dl_done_section": ("On this phone", "Neste teléfono"), + "dl_no_space": ("Not enough free space", "Non hai espazo libre abondo"), + "delete_all_downloads": ("Delete all downloads", "Borrar todas as descargas"), + "delete_all_confirm": ("Delete every downloaded track from this phone?", "Borrar deste teléfono todas as pistas descargadas?"), + "cancel_downloads": ("Cancel pending downloads", "Cancelar as descargas pendentes"), + "set_dl_quality": ("Download quality: %1$s", "Calidade das descargas: %1$s"), + "dl_quality_title": ("Download quality", "Calidade das descargas"), + "dl_quality_original": ("Original MP3s, others 320 kbps", "MP3 orixinais, o resto a 320 kbps"), + "set_delete_downloads": ("Delete all downloads (%1$s)", "Borrar todas as descargas (%1$s)"), + "downloaded": ("Downloaded", "Descargada"), + "quit_app": ("Close Minamp", "Pechar Minamp"), + "stalled": ("Playback is stuck. Try pausing and playing again.", "A reprodución quedou parada. Proba a pausar e reproducir de novo."), + # pure-Java layers (looked up by name through com.minamp.util.Tr) "tr_kind_home": ("Home network", "Rede da casa"), "tr_kind_direct": ("Direct", "Directa"), From 3caabd942c9f1e0e8aeacbd6811b1ba48ed9e538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sat, 17 Oct 2026 16:03:53 +0200 Subject: [PATCH 2/3] README: downloads, Close, home-first reconnect Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1b254bd..1da53ce 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ working one, re-checking whenever the phone switches network: | Order | Connection | When it's used | |---|---|---| -| 1 | Whatever worked last time | Tried alone first, so a normal start costs one request | +| 1 | Whatever worked last time | Tried alone first, so a normal start costs one request (if that was a remote address, the home addresses get 2 s first) | | 2 | Home network, plain HTTP | On your Wi-Fi (fast: no encryption work for the phone) | | 3 | Home network, HTTPS (`*.plex.direct`) | On your Wi-Fi if plain HTTP is refused, or if *Settings → Encrypt at home too* is on | | 4 | Remote, HTTPS | Away, if Remote Access is set up on the server | @@ -73,6 +73,12 @@ clock has been reset to before 2024 the certificate date check is skipped rather * Now Playing: big art on a backdrop coloured from the cover, seek bar, shuffle, repeat (queue/track), love, sleep timer (15–90 min or end of track), where it's streaming from. * Up Next queue: jump, play next, remove, clear; the queue and position survive restarts. +* Downloads for offline listening: long-press a track, album, artist or playlist -> *Download*; + *Menu -> Downloads* shows progress, space used and free, play/shuffle, retry and remove. Files go to + the SD card. MP3 originals are kept as they are (unless above a lower *Download quality* in + Settings); everything else is transcoded to MP3, 320 kbps by default. Downloaded tracks play from + the SD card, offline too, and plays made offline are reported to Plex later. +* *Menu -> Close Minamp* stops the music and closes every screen. * Wired headset button: 1 click play/pause, 2 next, 3 previous; Bluetooth media keys; pauses for calls and when headphones are unplugged. @@ -102,4 +108,3 @@ Tests (desktop JVM, no device): * No gapless playback (Android 2.2/2.3 have no API for it). * Notification has no playback buttons (pre-Android 3 can't); tap it to open Now Playing. -* Downloads for offline listening aren't implemented. From 9b88520ec377a02144b5836d7b5978e37b36cd3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sat, 17 Oct 2026 16:11:29 +0200 Subject: [PATCH 3/3] Minamp 1.8: offline downloads, home-first reconnect, stall warning, Close Co-Authored-By: Claude Opus 5.5 --- AndroidManifest.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 95f6aac..d3c498c 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@