Merge pull request 'Backport from the iOS version: offline downloads, home-first reconnect, stall warning, Close' (#1) from ios-backports into main

Reviewed-on: xocko/minamp#1
This commit was merged in pull request #1.
This commit is contained in:
xocko committed 2026-10-17 14:27:06 +00:00
commit 0f38670880
16 files changed
+942 -47

No files matched your search

+4 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.minamp" package="com.minamp"
android:versionCode="8" android:versionCode="9"
android:versionName="1.7" android:versionName="1.8"
android:installLocation="auto"> android:installLocation="auto">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" /> <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
@@ -50,6 +50,8 @@
android:launchMode="singleTop" /> android:launchMode="singleTop" />
<activity android:name=".QueueActivity" <activity android:name=".QueueActivity"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity android:name=".DownloadsActivity"
android:screenOrientation="portrait" />
<service android:name=".PlayerService" android:exported="false" /> <service android:name=".PlayerService" android:exported="false" />
+7 -2
View File
@@ -32,7 +32,7 @@ working one, re-checking whenever the phone switches network:
| Order | Connection | When it's used | | 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) | | 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 | | 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 | | 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 * 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. (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. * 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; * Wired headset button: 1 click play/pause, 2 next, 3 previous; Bluetooth media keys;
pauses for calls and when headphones are unplugged. 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). * 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. * Notification has no playback buttons (pre-Android 3 can't); tap it to open Now Playing.
* Downloads for offline listening aren't implemented.
+25
View File
@@ -7,6 +7,7 @@
<string name="address_hint">Address</string> <string name="address_hint">Address</string>
<string name="album">Album</string> <string name="album">Album</string>
<string name="album_link">Album ›</string> <string name="album_link">Album ›</string>
<string name="already_downloaded">Already downloaded</string>
<string name="app_name">Minamp</string> <string name="app_name">Minamp</string>
<string name="applies_next_track">Applies from the next track</string> <string name="applies_next_track">Applies from the next track</string>
<string name="artist">Artist</string> <string name="artist">Artist</string>
@@ -15,6 +16,7 @@
<string name="back">Back</string> <string name="back">Back</string>
<string name="buffering">Buffering…</string> <string name="buffering">Buffering…</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="cancel_downloads">Cancel pending downloads</string>
<string name="cant_list_servers">Couldn\'t list servers: %1$s</string> <string name="cant_list_servers">Couldn\'t list servers: %1$s</string>
<string name="cant_load_tap">Couldn\'t load: %1$s (tap to retry)</string> <string name="cant_load_tap">Couldn\'t load: %1$s (tap to retry)</string>
<string name="cant_reach_plextv">Couldn\'t reach plex.tv: %1$s</string> <string name="cant_reach_plextv">Couldn\'t reach plex.tv: %1$s</string>
@@ -34,8 +36,25 @@
<string name="contacting_plextv">Contacting plex.tv…\n(the first secure connection takes a few seconds on this phone)</string> <string name="contacting_plextv">Contacting plex.tv…\n(the first secure connection takes a few seconds on this phone)</string>
<string name="decade_radio_x">%1$ds radio</string> <string name="decade_radio_x">%1$ds radio</string>
<string name="decade_x">%1$ds</string> <string name="decade_x">%1$ds</string>
<string name="delete_all_confirm">Delete every downloaded track from this phone?</string>
<string name="delete_all_downloads">Delete all downloads</string>
<string name="dl_done_section">On this phone</string>
<string name="dl_empty">Nothing downloaded yet.\nLong-press an album, playlist or track and choose Download.</string>
<string name="dl_failed">Failed: %1$s (tap to retry)</string>
<string name="dl_in_progress">In progress</string>
<string name="dl_no_space">Not enough free space</string>
<string name="dl_progress">Downloading %1$d%%</string>
<string name="dl_quality_original">Original MP3s, others 320 kbps</string>
<string name="dl_quality_title">Download quality</string>
<string name="dl_retrying">Will retry when the server is reachable</string>
<string name="dl_summary">%1$d tracks · %2$s · %3$s free</string>
<string name="dl_waiting">Waiting</string>
<string name="download">Download</string>
<string name="download_first_toast">Download before playing: %1$s</string> <string name="download_first_toast">Download before playing: %1$s</string>
<string name="downloaded">Downloaded</string>
<string name="downloading_pct">Downloading %1$d%%</string> <string name="downloading_pct">Downloading %1$d%%</string>
<string name="downloading_x">Downloading %1$s…</string>
<string name="downloads">Downloads</string>
<string name="end_of_track">End of track</string> <string name="end_of_track">End of track</string>
<string name="enter_address">Enter your server\'s address</string> <string name="enter_address">Enter your server\'s address</string>
<string name="error_last_time">Minamp hit an error last time</string> <string name="error_last_time">Minamp hit an error last time</string>
@@ -108,12 +127,15 @@
<string name="quality_home_title">Quality at home</string> <string name="quality_home_title">Quality at home</string>
<string name="quality_original">Original</string> <string name="quality_original">Original</string>
<string name="queue_empty">The queue is empty</string> <string name="queue_empty">The queue is empty</string>
<string name="queued_n">%1$d tracks added to downloads</string>
<string name="quit_app">Close Minamp</string>
<string name="random_album">Random album</string> <string name="random_album">Random album</string>
<string name="random_album_sub">Feeling lucky</string> <string name="random_album_sub">Feeling lucky</string>
<string name="recently_added">Recently added</string> <string name="recently_added">Recently added</string>
<string name="recently_played">Recently played</string> <string name="recently_played">Recently played</string>
<string name="reconnecting">Reconnecting…</string> <string name="reconnecting">Reconnecting…</string>
<string name="refresh">Refresh</string> <string name="refresh">Refresh</string>
<string name="remove_download">Remove download</string>
<string name="remove_from_queue">Remove from queue</string> <string name="remove_from_queue">Remove from queue</string>
<string name="repeat_off">Repeat off</string> <string name="repeat_off">Repeat off</string>
<string name="repeat_queue">Repeat queue</string> <string name="repeat_queue">Repeat queue</string>
@@ -135,6 +157,8 @@
<string name="server_wants_token">The server wants a token (or sign in with Plex instead)</string> <string name="server_wants_token">The server wants a token (or sign in with Plex instead)</string>
<string name="server_yours">Yours</string> <string name="server_yours">Yours</string>
<string name="set_connection">Connection: %1$s</string> <string name="set_connection">Connection: %1$s</string>
<string name="set_delete_downloads">Delete all downloads (%1$s)</string>
<string name="set_dl_quality">Download quality: %1$s</string>
<string name="set_download_first">Download tracks before playing: %1$s</string> <string name="set_download_first">Download tracks before playing: %1$s</string>
<string name="set_language">Language: %1$s</string> <string name="set_language">Language: %1$s</string>
<string name="set_library">Library: %1$s</string> <string name="set_library">Library: %1$s</string>
@@ -175,6 +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="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>
+25
View File
@@ -7,6 +7,7 @@
<string name="address_hint">Enderezo</string> <string name="address_hint">Enderezo</string>
<string name="album">Álbum</string> <string name="album">Álbum</string>
<string name="album_link">Álbum ›</string> <string name="album_link">Álbum ›</string>
<string name="already_downloaded">Xa está descargado</string>
<string name="app_name">Minamp</string> <string name="app_name">Minamp</string>
<string name="applies_next_track">Aplícase desde a seguinte pista</string> <string name="applies_next_track">Aplícase desde a seguinte pista</string>
<string name="artist">Artista</string> <string name="artist">Artista</string>
@@ -15,6 +16,7 @@
<string name="back">Volver</string> <string name="back">Volver</string>
<string name="buffering">Cargando…</string> <string name="buffering">Cargando…</string>
<string name="cancel">Cancelar</string> <string name="cancel">Cancelar</string>
<string name="cancel_downloads">Cancelar as descargas pendentes</string>
<string name="cant_list_servers">Non se puideron listar os servidores: %1$s</string> <string name="cant_list_servers">Non se puideron listar os servidores: %1$s</string>
<string name="cant_load_tap">Non se puido cargar: %1$s (toca para tentar de novo)</string> <string name="cant_load_tap">Non se puido cargar: %1$s (toca para tentar de novo)</string>
<string name="cant_reach_plextv">Non se puido contactar con plex.tv: %1$s</string> <string name="cant_reach_plextv">Non se puido contactar con plex.tv: %1$s</string>
@@ -34,8 +36,25 @@
<string name="contacting_plextv">Contactando con plex.tv…\n(a primeira conexión segura tarda uns segundos neste teléfono)</string> <string name="contacting_plextv">Contactando con plex.tv…\n(a primeira conexión segura tarda uns segundos neste teléfono)</string>
<string name="decade_radio_x">Radio da década de %1$d</string> <string name="decade_radio_x">Radio da década de %1$d</string>
<string name="decade_x">Década de %1$d</string> <string name="decade_x">Década de %1$d</string>
<string name="delete_all_confirm">Borrar deste teléfono todas as pistas descargadas?</string>
<string name="delete_all_downloads">Borrar todas as descargas</string>
<string name="dl_done_section">Neste teléfono</string>
<string name="dl_empty">Aínda non descargaches nada.\nMantén premido un álbum, lista ou pista e escolle Descargar.</string>
<string name="dl_failed">Fallou: %1$s (toca para tentar de novo)</string>
<string name="dl_in_progress">En curso</string>
<string name="dl_no_space">Non hai espazo libre abondo</string>
<string name="dl_progress">Descargando %1$d%%</string>
<string name="dl_quality_original">MP3 orixinais, o resto a 320 kbps</string>
<string name="dl_quality_title">Calidade das descargas</string>
<string name="dl_retrying">Tentarase de novo cando se poida conectar co servidor</string>
<string name="dl_summary">%1$d pistas · %2$s · %3$s libres</string>
<string name="dl_waiting">Agardando</string>
<string name="download">Descargar</string>
<string name="download_first_toast">Descargar antes de reproducir: %1$s</string> <string name="download_first_toast">Descargar antes de reproducir: %1$s</string>
<string name="downloaded">Descargada</string>
<string name="downloading_pct">Descargando %1$d%%</string> <string name="downloading_pct">Descargando %1$d%%</string>
<string name="downloading_x">Descargando %1$s…</string>
<string name="downloads">Descargas</string>
<string name="end_of_track">Fin da pista</string> <string name="end_of_track">Fin da pista</string>
<string name="enter_address">Escribe o enderezo do teu servidor</string> <string name="enter_address">Escribe o enderezo do teu servidor</string>
<string name="error_last_time">Minamp tivo un erro a última vez</string> <string name="error_last_time">Minamp tivo un erro a última vez</string>
@@ -108,12 +127,15 @@
<string name="quality_home_title">Calidade na casa</string> <string name="quality_home_title">Calidade na casa</string>
<string name="quality_original">Orixinal</string> <string name="quality_original">Orixinal</string>
<string name="queue_empty">A cola está baleira</string> <string name="queue_empty">A cola está baleira</string>
<string name="queued_n">%1$d pistas engadidas ás descargas</string>
<string name="quit_app">Pechar Minamp</string>
<string name="random_album">Álbum ao chou</string> <string name="random_album">Álbum ao chou</string>
<string name="random_album_sub">Proba sorte</string> <string name="random_album_sub">Proba sorte</string>
<string name="recently_added">Novidades</string> <string name="recently_added">Novidades</string>
<string name="recently_played">Últimas escoitas</string> <string name="recently_played">Últimas escoitas</string>
<string name="reconnecting">Reconectando…</string> <string name="reconnecting">Reconectando…</string>
<string name="refresh">Actualizar</string> <string name="refresh">Actualizar</string>
<string name="remove_download">Borrar a descarga</string>
<string name="remove_from_queue">Quitar da cola</string> <string name="remove_from_queue">Quitar da cola</string>
<string name="repeat_off">Sen repetición</string> <string name="repeat_off">Sen repetición</string>
<string name="repeat_queue">Repetir a cola</string> <string name="repeat_queue">Repetir a cola</string>
@@ -135,6 +157,8 @@
<string name="server_wants_token">O servidor pide un token (ou inicia sesión con Plex)</string> <string name="server_wants_token">O servidor pide un token (ou inicia sesión con Plex)</string>
<string name="server_yours">Teu</string> <string name="server_yours">Teu</string>
<string name="set_connection">Conexión: %1$s</string> <string name="set_connection">Conexión: %1$s</string>
<string name="set_delete_downloads">Borrar todas as descargas (%1$s)</string>
<string name="set_dl_quality">Calidade das descargas: %1$s</string>
<string name="set_download_first">Descargar as pistas antes de reproducilas: %1$s</string> <string name="set_download_first">Descargar as pistas antes de reproducilas: %1$s</string>
<string name="set_language">Idioma: %1$s</string> <string name="set_language">Idioma: %1$s</string>
<string name="set_library">Biblioteca: %1$s</string> <string name="set_library">Biblioteca: %1$s</string>
@@ -175,6 +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="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>
+39
View File
@@ -22,6 +22,7 @@ import android.os.Handler;
import com.minamp.net.CertVerifier; import com.minamp.net.CertVerifier;
import com.minamp.net.Http; import com.minamp.net.Http;
import com.minamp.plex.Item;
import com.minamp.plex.Library; import com.minamp.plex.Library;
import com.minamp.plex.Plex; import com.minamp.plex.Plex;
import com.minamp.plex.Server; import com.minamp.plex.Server;
@@ -83,6 +84,8 @@ public class App extends Application {
handler.post(new Runnable() { handler.post(new Runnable() {
public void run() { public void run() {
if (server == s) prefs.edit().putString("server", s.toJson().toString()).commit(); 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) { @Override public void onReceive(Context c, Intent i) {
// Network changed (Wi-Fi <-> mobile, new Wi-Fi...): re-pick the best connection lazily. // Network changed (Wi-Fi <-> mobile, new Wi-Fi...): re-pick the best connection lazily.
if (server != null) server.invalidate(); if (server != null) server.invalidate();
Downloads.get().kick();
} }
}, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); }, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
} }
@@ -232,6 +236,41 @@ public class App extends Application {
library = new Library(server, key); 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 boolean isConfigured() { return server != null && library != null; }
public String sectionTitle() { return prefs.getString("sectionTitle", "Music"); } public String sectionTitle() { return prefs.getString("sectionTitle", "Music"); }
+80 -9
View File
@@ -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.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); } 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); 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()) { } else if (it.isContainer()) {
labels.add(str(R.string.play)); actions.add(0); 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(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.play_next)); actions.add(1);
labels.add(str(R.string.add_to_queue)); actions.add(2); 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); } 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; } else return;
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setTitle(it.title) .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 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 6: openKey(it.artistKey, Item.ARTIST, it.type == Item.ALBUM ? it.artist : it.artist); break;
case 7: toggleLove(it, null); 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<Item> one = new ArrayList<Item>();
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<List<Item>>() {
public List<Item> work() throws Exception { return lib.tracksOf(it); }
public void done(List<Item> 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) { protected void openKey(String ratingKey, int type, String title) {
Item i = new Item(); Item i = new Item();
i.type = type; i.type = type;
@@ -376,7 +403,9 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
public boolean onCreateOptionsMenu(Menu menu) { public boolean onCreateOptionsMenu(Menu menu) {
menu.add(0, 1, 0, R.string.now_playing); menu.add(0, 1, 0, R.string.now_playing);
menu.add(0, 2, 0, R.string.search); 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, 3, 0, R.string.settings);
menu.add(0, 5, 0, R.string.quit_app);
return true; 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 1: startActivity(new Intent(this, NowPlayingActivity.class)); return true;
case 2: startActivity(new Intent(this, SearchActivity.class)); return true; case 2: startActivity(new Intent(this, SearchActivity.class)); return true;
case 3: showSettings(); 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); 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() { protected void showSettings() {
Server s = app.server; Server s = app.server;
String conn = s == null || s.active() == null ? str(R.string.not_connected_yet) : Server.kindLabel(s.active().kind) 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 = { 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_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_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_secure_home, str(Server.preferSecureAtHome ? R.string.on : R.string.off)),
str(R.string.set_library, app.sectionTitle()), str(R.string.set_library, app.sectionTitle()),
@@ -414,19 +454,21 @@ 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: case 2: pickDownloadQuality(); break;
case 3: confirmDeleteDownloads(); break;
case 4:
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 3: case 5:
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 4: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break; case 6: 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 7: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break;
case 6: case 8:
if (app.server != null) { if (app.server != null) {
app.server.invalidate(); app.server.invalidate();
toast(str(R.string.reconnecting)); toast(str(R.string.reconnecting));
@@ -438,10 +480,10 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
}); });
} }
break; break;
case 7: showDiagnostics(); break; case 9: showDiagnostics(); break;
case 8: pickLanguage(); break; case 10: pickLanguage(); break;
case 9: pickSize(); break; case 11: pickSize(); break;
case 10: confirmSignOut(); break; case 12: confirmSignOut(); break;
} }
} }
}).show(); }).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) { private String qualityLabel(int kbps) {
if (kbps <= 0) return str(R.string.quality_original); 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]; for (int i = 0; i < App.QUALITIES.length; i++) if (App.QUALITIES[i] == kbps) return App.QUALITY_LABELS[i];
+435
View File
@@ -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/<ratingKey>.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<Entry> list = new ArrayList<Entry>();
private final Map<String, Entry> byKey = new HashMap<String, Entry>();
private final List<Listener> listeners = new ArrayList<Listener>();
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<Entry> pending() {
List<Entry> out = new ArrayList<Entry>();
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<Entry> done() {
List<Entry> out = new ArrayList<Entry>();
for (int i = 0; i < list.size(); i++) if (list.get(i).state == DONE) out.add(list.get(i));
Collections.sort(out, new Comparator<Entry>() {
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<Item> doneTracks() {
List<Entry> d = done();
List<Item> out = new ArrayList<Item>();
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<Item> 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<Entry> all = new ArrayList<Entry>(list);
for (int i = 0; i < all.size(); i++) remove(all.get(i).item);
}
public void cancelPending() {
List<Entry> 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<String> urls;
if (direct) { urls = new ArrayList<String>(); urls.add(lib.directUrl(t)); }
else urls = lib.transcodeUrls(t, quality > 0 ? quality : 320, 0, session);
int[] order = direct ? null : Library.variantOrder();
Map<String, String> 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();
}
}
+136
View File
@@ -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<Downloads.Entry> pending = new ArrayList<Downloads.Entry>();
@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<Item> 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<Item> rows = new ArrayList<Item>();
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<Item> 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();
}
}
+1
View File
@@ -90,6 +90,7 @@ public class HomeActivity extends BaseActivity {
@Override @Override
protected void onNewIntent(Intent intent) { protected void onNewIntent(Intent intent) {
super.onNewIntent(intent); super.onNewIntent(intent);
if (intent.getBooleanExtra("exit", false)) { finish(); return; } // "Close Minamp"
if (intent.getBooleanExtra("relaunch", false)) { if (intent.getBooleanExtra("relaunch", false)) {
// language changed: rebuild from scratch so every inflated text is re-read // language changed: rebuild from scratch so every inflated text is re-read
finish(); finish();
+10 -2
View File
@@ -20,15 +20,19 @@ public class ItemAdapter extends BaseAdapter {
public interface Pager { void loadMore(int from); } 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 Context ctx;
private final LayoutInflater inflater; private final LayoutInflater inflater;
private final List<Item> items = new ArrayList<Item>(); private final List<Item> items = new ArrayList<Item>();
private final int mode; private final int mode;
private final int artPx; 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 int total = -1; // total available on the server, -1 = everything loaded
private boolean loadingMore; private boolean loadingMore;
private Pager pager; private Pager pager;
private Decorator decorator;
private String albumArtist = ""; private String albumArtist = "";
private int highlight = -1; // queue index highlight private int highlight = -1; // queue index highlight
private final App app; private final App app;
@@ -40,10 +44,12 @@ public class ItemAdapter extends BaseAdapter {
artPx = Ui.dimen(c, R.dimen.row_art); artPx = Ui.dimen(c, R.dimen.row_art);
accent = c.getResources().getColor(R.color.accent); accent = c.getResources().getColor(R.color.accent);
text = c.getResources().getColor(R.color.text); text = c.getResources().getColor(R.color.text);
sub2 = c.getResources().getColor(R.color.text2);
app = App.get(); app = App.get();
} }
public void setPager(Pager p) { pager = p; } 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 setAlbumArtist(String a) { albumArtist = a == null ? "" : a; }
public void setHighlight(int i) { highlight = i; notifyDataSetChanged(); } public void setHighlight(int i) { highlight = i; notifyDataSetChanged(); }
@@ -132,7 +138,7 @@ public class ItemAdapter extends BaseAdapter {
h.art.setVisibility(View.VISIBLE); h.art.setVisibility(View.VISIBLE);
h.num.setVisibility(View.GONE); h.num.setVisibility(View.GONE);
app.images.load(h.art, it.thumb, artPx, R.drawable.art_placeholder); 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.setVisibility(sub.length() > 0 ? View.VISIBLE : View.GONE);
h.sub.setText(sub); h.sub.setText(sub);
} }
@@ -140,6 +146,8 @@ public class ItemAdapter extends BaseAdapter {
String right = ""; String right = "";
if (it.type == Item.TRACK && it.duration > 0) right = Ui.time(it.duration); if (it.type == Item.TRACK && it.duration > 0) right = Ui.time(it.duration);
h.right.setText(right); 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; boolean current;
if (mode == MODE_QUEUE) current = pos == highlight; if (mode == MODE_QUEUE) current = pos == highlight;
+36 -1
View File
@@ -89,6 +89,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
public static String sourceLabel() { public static String sourceLabel() {
PlayerService s = instance; PlayerService s = instance;
if (s == null || s.track == null) return ""; if (s == null || s.track == null) return "";
if (s.localFile) return s.getString(R.string.downloaded);
App app = App.get(); App app = App.get();
String where = app.server == null || app.server.active() == null ? "" : com.minamp.plex.Server.kindLabel(app.server.active().kind); 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" : "")); 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 lastTimeline;
private long lastSave; private long lastSave;
private long lastPos; // last position seen while playing (getCurrentPosition is unreliable after completion) 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 AudioManager audio;
private boolean hasFocus; private boolean hasFocus;
@@ -265,6 +270,17 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
setState(BUFFERING, getString(R.string.connecting)); setState(BUFFERING, getString(R.string.connecting));
goForeground(); goForeground();
if (play) requestFocus(); 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<String>(), "mp3", saved, 0, posMs);
return;
}
final boolean download = forceDownload || app.downloadFirst(); final boolean download = forceDownload || app.downloadFirst();
worker.execute(new Runnable() { worker.execute(new Runnable() {
public void run() { public void run() {
@@ -605,6 +621,16 @@ 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
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 (!scrobbled && track.duration > 0 && pos > track.duration * 9 / 10) scrobble(track);
if (now - lastTimeline > 10000) report(false); if (now - lastTimeline > 10000) report(false);
if (now - lastSave > 15000) { lastSave = now; PlayQueue.get().resumePositionMs = pos; } if (now - lastSave > 15000) { lastSave = now; PlayQueue.get().resumePositionMs = pos; }
@@ -631,7 +657,16 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
scrobbled = true; scrobbled = true;
final App app = App.get(); final App app = App.get();
reporter.execute(new Runnable() { 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);
}
}); });
} }
+57 -31
View File
@@ -134,10 +134,14 @@ public final class Server {
public volatile String lastGoodUri = ""; public volatile String lastGoodUri = "";
private String probe(Conn c) { 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(); long t0 = System.currentTimeMillis();
try { 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, timeoutMs);
Json.Obj o = Plex.fetchJson("GET", c.uri + "/identity", token, lan ? 5000 : 15000);
Json.Obj mc = o.optObj("MediaContainer"); Json.Obj mc = o.optObj("MediaContainer");
String id = mc == null ? "" : mc.optString("machineIdentifier"); String id = mc == null ? "" : mc.optString("machineIdentifier");
if (machineId.length() > 0 && !machineId.equals(id)) return "a different server answered"; if (machineId.length() > 0 && !machineId.equals(id)) return "a different server answered";
@@ -164,6 +168,39 @@ public final class Server {
return c; return c;
} }
/** Probes conns in parallel; the first that answers wins (null if none within waitMs). */
private Conn firstOf(final List<Conn> 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 * 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): * 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; String firstError = null;
List<Conn> tried = new ArrayList<Conn>(); List<Conn> tried = new ArrayList<Conn>();
// 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<Conn> home = new ArrayList<Conn>();
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 // 1. last known good
for (int i = 0; i < order.size(); i++) { for (int i = 0; i < order.size(); i++) {
Conn c = order.get(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)); 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()) { if (!plain.isEmpty()) {
final String[] errs = new String[plain.size()]; String[] err = new String[1];
final int[] state = new int[plain.size()]; Conn w = firstOf(plain, 5000, 7000, err);
final Object lock = new Object(); if (w != null) return choose(w);
for (int i = 0; i < plain.size(); i++) { tried.addAll(plain);
final int idx = i; if (firstError == null) firstError = err[0];
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];
}
} }
// 3. everything else, one at a time, in preference order // 3. everything else, one at a time, in preference order
+42
View File
@@ -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);
}
}
+15
View File
@@ -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()
+2
View File
@@ -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 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, ()), ("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, ()), ("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, ()), ("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, ("status message", ["queue_empty", "no_playlists", "pick_something", "nothing_playing"], D["text_status"], False,
lambda W: W - 48, 2, ()), lambda W: W - 48, 2, ()),
+28
View File
@@ -230,6 +230,34 @@ S = {
"searching": ("Searching…", "Buscando…"), "searching": ("Searching…", "Buscando…"),
"nothing_found_q": ("Nothing found for “%1$s”", "Non se atopou nada para “%1$s”"), "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) # pure-Java layers (looked up by name through com.minamp.util.Tr)
"tr_kind_home": ("Home network", "Rede da casa"), "tr_kind_home": ("Home network", "Rede da casa"),
"tr_kind_direct": ("Direct", "Directa"), "tr_kind_direct": ("Direct", "Directa"),