Import miniabs 1.0 source

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-14 19:26:04 +02:00
commit 4fe9eed870
78 files changed
+5800

No files matched your search

+542
View File
@@ -0,0 +1,542 @@
package com.miniabs;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.ProtocolException;
import java.net.Socket;
import java.net.URL;
import java.net.URLEncoder;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import android.content.Context;
import android.os.Build;
/** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */
final class Api {
static final String VERSION = "1.0";
static class ApiException extends IOException {
final int code;
ApiException(int code, String msg) {
super(msg);
this.code = code;
}
}
/** Sign-in is no longer valid; the user must log in again. */
static class AuthException extends IOException {
AuthException(String msg) {
super(msg);
}
}
private static final Object REFRESH_LOCK = new Object();
private static SSLSocketFactory trustAllFactory;
static {
// Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off.
System.setProperty("http.keepAlive", "false");
}
private Api() {
}
// ------------------------------------------------------------------ plumbing
static HttpURLConnection open(Context c, String url, String method) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
conn.setUseCaches(false);
conn.setInstanceFollowRedirects(true);
if (conn instanceof HttpsURLConnection && Prefs.get(c).trustAll()) trustAll((HttpsURLConnection) conn);
setMethod(conn, method);
conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
return conn;
}
private static void setMethod(HttpURLConnection conn, String method) throws ProtocolException {
conn.setRequestMethod(method);
}
private static synchronized SSLSocketFactory trustAllFactory() throws Exception {
if (trustAllFactory == null) {
TrustManager tm = new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String type) {
}
public void checkServerTrusted(X509Certificate[] chain, String type) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[]{tm}, null);
trustAllFactory = ctx.getSocketFactory();
}
return trustAllFactory;
}
private static void trustAll(HttpsURLConnection conn) {
try {
conn.setSSLSocketFactory(trustAllFactory());
conn.setHostnameVerifier(new HostnameVerifier() {
public boolean verify(String host, SSLSession session) {
return true;
}
});
} catch (Exception ignored) {
}
}
/**
* PATCH over a plain socket. Android 2.x's HttpURLConnection only sends a body with POST or PUT,
* and Audiobookshelf uses PATCH for "mark as played" and for saving progress without a session.
*/
private static Object rawPatch(Context c, String path, JSONObject body, boolean mayRefresh) throws IOException {
Prefs p = Prefs.get(c);
if (mayRefresh) ensureFresh(c, 120);
String used = p.token();
URL u = new URL(p.server() + path);
boolean https = "https".equalsIgnoreCase(u.getProtocol());
int port = u.getPort() > 0 ? u.getPort() : (https ? 443 : 80);
Socket sock = new Socket();
int code;
try {
sock.connect(new InetSocketAddress(u.getHost(), port), 15000);
sock.setSoTimeout(30000);
Socket io = sock;
if (https) {
boolean trust = p.trustAll();
SSLSocketFactory f;
try {
f = trust ? trustAllFactory() : (SSLSocketFactory) SSLSocketFactory.getDefault();
} catch (Exception e) {
throw new SSLException("TLS unavailable");
}
SSLSocket ss = (SSLSocket) f.createSocket(sock, u.getHost(), port, true);
ss.startHandshake();
if (!trust && !HttpsURLConnection.getDefaultHostnameVerifier().verify(u.getHost(), ss.getSession())) {
ss.close();
throw new SSLException("Certificate doesn't match " + u.getHost());
}
io = ss;
}
byte[] b = (body == null ? "{}" : body.toString()).getBytes("UTF-8");
String host = u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
String head = "PATCH " + u.getFile() + " HTTP/1.1\r\n"
+ "Host: " + host + "\r\n"
+ "Authorization: Bearer " + used + "\r\n"
+ "Content-Type: application/json\r\n"
+ "Accept: application/json\r\n"
+ "User-Agent: MiniABS/" + VERSION + "\r\n"
+ "Content-Length: " + b.length + "\r\n"
+ "Connection: close\r\n\r\n";
OutputStream os = io.getOutputStream();
os.write(head.getBytes("UTF-8"));
os.write(b);
os.flush();
InputStream in = io.getInputStream();
StringBuilder line = new StringBuilder();
int ch;
while ((ch = in.read()) != -1 && ch != '\n' && line.length() < 200) line.append((char) ch);
String[] parts = line.toString().trim().split(" ");
if (parts.length < 2 || !parts[0].startsWith("HTTP/")) throw new IOException("Unexpected reply from server");
try {
code = Integer.parseInt(parts[1]);
} catch (NumberFormatException e) {
throw new IOException("Unexpected reply from server");
}
byte[] rest = new byte[1024];
int total = 0, n;
while (total < 16384 && (n = in.read(rest)) > 0) total += n; // read the reply politely
io.close();
} finally {
try {
sock.close();
} catch (IOException ignored) {
}
}
if (code == 401) {
if (mayRefresh && p.canRefresh()) {
refreshIf(c, used, 0);
return rawPatch(c, path, body, false);
}
throw new AuthException("Your sign-in has expired. Please log in again.");
}
if (code >= 400) throw new ApiException(code, "");
if (code >= 300) throw new ApiException(code, "The server redirected; check the address in Settings.");
return "OK";
}
/** Old Android throws instead of returning 401 when the server sends no WWW-Authenticate header. */
static int responseCode(HttpURLConnection conn) throws IOException {
try {
return conn.getResponseCode();
} catch (IOException e) {
String m = e.getMessage();
if (m != null && m.toLowerCase().contains("authentication challenge")) return 401;
throw e;
}
}
private static void writeBody(HttpURLConnection conn, JSONObject body) throws IOException {
byte[] b = (body == null ? "{}" : body.toString()).getBytes("UTF-8");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setFixedLengthStreamingMode(b.length);
OutputStream os = conn.getOutputStream();
try {
os.write(b);
} finally {
os.close();
}
}
private static Object readBody(HttpURLConnection conn, int maxString, String[] deepSkip) throws IOException {
InputStream is = new BufferedInputStream(conn.getInputStream(), 8192);
try {
String type = conn.getContentType();
Reader r = new InputStreamReader(is, "UTF-8");
if (type != null && type.contains("json")) return Json.parse(r, maxString, deepSkip);
StringBuilder sb = new StringBuilder();
char[] b = new char[512];
int n;
while ((n = r.read(b)) > 0 && sb.length() < 4000) sb.append(b, 0, n);
return sb.toString();
} finally {
is.close();
}
}
private static String errorText(HttpURLConnection conn) {
try {
InputStream es = conn.getErrorStream();
if (es == null) return "";
Reader r = new InputStreamReader(es, "UTF-8");
StringBuilder sb = new StringBuilder();
char[] b = new char[256];
int n;
while ((n = r.read(b)) > 0 && sb.length() < 200) sb.append(b, 0, n);
es.close();
String t = sb.toString().trim();
if (t.startsWith("<")) return ""; // an HTML error page is no use on a tiny screen
return t.length() > 120 ? t.substring(0, 120) : t;
} catch (Exception e) {
return "";
}
}
static Object call(Context c, String method, String path, JSONObject body) throws IOException {
return call(c, method, path, body, 2000, null, true);
}
static Object call(Context c, String method, String path, JSONObject body, int maxString, String[] deepSkip,
boolean mayRefresh) throws IOException {
Prefs p = Prefs.get(c);
if (!p.loggedIn()) throw new AuthException("Please log in.");
if ("PATCH".equals(method)) return rawPatch(c, path, body, mayRefresh);
if (mayRefresh) ensureFresh(c, 120);
String used = p.token();
HttpURLConnection conn = open(c, p.server() + path, method);
try {
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + used);
if (body != null || "POST".equals(method)) writeBody(conn, body);
int code = responseCode(conn);
if (code == 401) {
conn.disconnect();
if (mayRefresh && p.canRefresh()) {
refreshIf(c, used, 0);
return call(c, method, path, body, maxString, deepSkip, false);
}
throw new AuthException("Your sign-in has expired. Please log in again.");
}
if (code >= 400) throw new ApiException(code, errorText(conn));
return readBody(conn, maxString, deepSkip);
} finally {
conn.disconnect();
}
}
static JSONObject obj(Object o) throws IOException {
if (o instanceof JSONObject) return (JSONObject) o;
throw new IOException("Unexpected reply from server");
}
// ------------------------------------------------------------------ sign-in
/** Checks the address points at an Audiobookshelf server. Returns its version, or "" if unknown. */
static String status(Context c, String server) throws IOException {
HttpURLConnection conn = open(c, server + "/status", "GET");
try {
int code = responseCode(conn);
if (code == 404) throw new IOException("No Audiobookshelf server at this address.");
if (code >= 400) throw new ApiException(code, errorText(conn));
Object o = readBody(conn, 200, null);
if (!(o instanceof JSONObject)) throw new IOException("No Audiobookshelf server at this address.");
JSONObject s = (JSONObject) o;
if (s.has("isInit") && !s.optBoolean("isInit", true)) {
throw new IOException("This server hasn't been set up yet. Finish setup in a browser first.");
}
return Util.str(s, "serverVersion");
} finally {
conn.disconnect();
}
}
/** Logs in with username and password. Returns the user's default library id (may be empty). */
static String login(Context c, String server, String user, String pass) throws IOException {
HttpURLConnection conn = open(c, server + "/login", "POST");
try {
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("x-return-tokens", "true");
JSONObject body = new JSONObject();
try {
body.put("username", user).put("password", pass);
} catch (JSONException ignored) {
}
writeBody(conn, body);
int code = responseCode(conn);
if (code == 401) throw new ApiException(401, "Wrong username or password.");
if (code == 429) throw new ApiException(429, "Too many attempts. Wait a minute and try again.");
if (code >= 400) throw new ApiException(code, errorText(conn));
JSONObject o = obj(readBody(conn, 500, null));
JSONObject u = o.optJSONObject("user");
if (u == null) throw new IOException("Unexpected reply from server");
String access = Util.str(u, "accessToken");
String refresh = Util.str(u, "refreshToken");
String legacy = Util.str(u, "token");
Prefs p = Prefs.get(c);
if (access.length() > 0) p.setAuth(user, Prefs.MODE_JWT, access, refresh);
else if (legacy.length() > 0) p.setAuth(user, Prefs.MODE_LEGACY, legacy, "");
else throw new IOException("The server didn't return a sign-in token.");
return Util.str(o, "userDefaultLibraryId");
} finally {
conn.disconnect();
}
}
/** Signs in with an API key by checking it against /api/me. Returns the username. */
static String loginWithKey(Context c, String key) throws IOException {
Prefs p = Prefs.get(c);
p.setAuth("", Prefs.MODE_KEY, key, "");
try {
JSONObject me = obj(call(c, "GET", "/api/me", null, 100, null, false));
String name = Util.str(me, "username");
p.setAuth(name, Prefs.MODE_KEY, key, "");
return name;
} catch (IOException e) {
p.logout();
if (e instanceof AuthException) throw new ApiException(401, "That API key wasn't accepted.");
throw e;
}
}
/** Refreshes the access token if it has less than minSeconds left. */
static void ensureFresh(Context c, long minSeconds) throws IOException {
Prefs p = Prefs.get(c);
if (!p.canRefresh()) return;
if (p.tokenSecondsLeft() >= minSeconds) return;
refreshIf(c, null, minSeconds);
}
/** Gets a new access token now (after a 401, or before reconnecting a stream). */
static void refresh(Context c, boolean force) throws IOException {
refreshIf(c, force ? Prefs.get(c).token() : null, 120);
}
/**
* stale: the token the server just rejected. If another thread has already replaced it,
* there's nothing to do. With no stale token, refreshes only if fewer than minSeconds are left.
*/
private static void refreshIf(Context c, String stale, long minSeconds) throws IOException {
synchronized (REFRESH_LOCK) {
Prefs p = Prefs.get(c);
if (!p.canRefresh()) throw new AuthException("Your sign-in has expired. Please log in again.");
if (stale != null) {
if (!stale.equals(p.token())) return;
} else if (p.tokenSecondsLeft() >= minSeconds) {
return;
}
HttpURLConnection conn = open(c, p.server() + "/auth/refresh", "POST");
try {
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("x-refresh-token", p.refreshToken());
writeBody(conn, null);
int code = responseCode(conn);
if (code == 401 || code == 403) {
throw new AuthException("Your sign-in has expired. Please log in again.");
}
if (code >= 400) throw new ApiException(code, errorText(conn));
JSONObject u = obj(readBody(conn, 500, null)).optJSONObject("user");
String access = u == null ? "" : Util.str(u, "accessToken");
if (access.length() == 0) throw new AuthException("Your sign-in has expired. Please log in again.");
p.setTokens(access, Util.str(u, "refreshToken"));
} finally {
conn.disconnect();
}
}
}
// ------------------------------------------------------------------ URLs
static String enc(String s) {
try {
return URLEncoder.encode(s, "UTF-8");
} catch (Exception e) {
return s;
}
}
/** Appends the access token as a query parameter (MediaPlayer on Android 2.x can't send headers). */
static String withToken(Context c, String url) {
return url + (url.indexOf('?') >= 0 ? "&" : "?") + "token=" + enc(Prefs.get(c).token());
}
/** Turns a server-relative content URL into a full URL, handling servers hosted under a sub-path. */
static String absolute(Context c, String contentUrl) {
if (contentUrl.startsWith("http://") || contentUrl.startsWith("https://")) return contentUrl;
String server = Prefs.get(c).server();
try {
URL u = new URL(server);
String base = u.getPath();
if (base.length() > 1 && contentUrl.startsWith(base + "/")) {
String origin = u.getProtocol() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
return origin + contentUrl;
}
} catch (Exception ignored) {
}
return server + (contentUrl.startsWith("/") ? "" : "/") + contentUrl;
}
static String coverUrl(Context c, String itemId, int width) {
return withToken(c, Prefs.get(c).server() + "/api/items/" + itemId + "/cover?format=jpeg&width=" + width);
}
static String[] downloadUrls(Context c, Episode e) {
String base = Prefs.get(c).server() + "/api/items/" + e.itemId + "/file/" + e.ino;
return new String[]{withToken(c, base + "/download"), withToken(c, base)};
}
// ------------------------------------------------------------------ data
static JSONArray libraries(Context c) throws IOException {
Object o = call(c, "GET", "/api/libraries", null);
if (o instanceof JSONArray) return (JSONArray) o;
JSONArray a = obj(o).optJSONArray("libraries");
return a == null ? new JSONArray() : a;
}
static JSONObject libraryItems(Context c, String lib, int page, int limit) throws IOException {
return obj(call(c, "GET", "/api/libraries/" + lib + "/items?minified=1&sort=media.metadata.title&limit="
+ limit + "&page=" + page, null, 300, null, true));
}
static JSONObject recentEpisodes(Context c, String lib, int page, int limit) throws IOException {
return obj(call(c, "GET", "/api/libraries/" + lib + "/recent-episodes?limit=" + limit + "&page=" + page,
null, 1200, new String[]{"episodes", "audioFiles", "tracks"}, true));
}
static JSONObject itemsInProgress(Context c) throws IOException {
return obj(call(c, "GET", "/api/me/items-in-progress?limit=15", null, 1200,
new String[]{"episodes"}, true));
}
/** A podcast with all its episodes. Show notes are cut to keep memory low. */
static JSONObject item(Context c, String itemId) throws IOException {
return obj(call(c, "GET", "/api/items/" + itemId, null, 800, null, true));
}
static JSONObject me(Context c) throws IOException {
return obj(call(c, "GET", "/api/me", null, 200, null, true));
}
private static JSONObject deviceInfo(Context c) throws JSONException {
return new JSONObject()
.put("deviceId", Prefs.get(c).deviceId())
.put("clientName", "MiniABS")
.put("clientVersion", VERSION)
.put("manufacturer", Build.MANUFACTURER)
.put("model", Build.MODEL)
.put("sdkVersion", Build.VERSION.SDK_INT);
}
/** Opens a playback session. Asks for direct play: Android 2.x can't play HLS transcodes. */
static JSONObject startPlayback(Context c, Episode e) throws IOException {
JSONObject body = new JSONObject();
try {
JSONArray mimes = new JSONArray();
String[] ok = {"audio/mpeg", "audio/mp3", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/x-aac",
"audio/ogg", "audio/wav", "audio/x-wav", "audio/3gpp", "audio/amr"};
for (String m : ok) mimes.put(m);
body.put("deviceInfo", deviceInfo(c))
.put("forceDirectPlay", true)
.put("forceTranscode", false)
.put("supportedMimeTypes", mimes)
.put("mediaPlayer", "MiniABS");
} catch (JSONException ignored) {
}
return obj(call(c, "POST", "/api/items/" + e.itemId + "/play/" + e.id, body, 200,
new String[]{"episodes"}, true));
}
private static JSONObject syncBody(double cur, double listened, double dur) {
JSONObject b = new JSONObject();
try {
b.put("currentTime", cur).put("timeListened", listened);
if (dur > 0) b.put("duration", dur);
} catch (JSONException ignored) {
}
return b;
}
static void syncSession(Context c, String sid, double cur, double listened, double dur) throws IOException {
call(c, "POST", "/api/session/" + sid + "/sync", syncBody(cur, listened, dur));
}
static void closeSession(Context c, String sid, double cur, double listened, double dur) throws IOException {
call(c, "POST", "/api/session/" + sid + "/close", syncBody(cur, listened, dur));
}
/** Saves progress without a session (offline catch-up, mark played/unplayed). */
static void setProgress(Context c, String itemId, String epId, double cur, double dur, Boolean finished)
throws IOException {
JSONObject b = new JSONObject();
try {
if (cur >= 0) {
b.put("currentTime", cur);
if (dur > 0) {
b.put("duration", dur);
b.put("progress", Math.min(1.0, cur / dur));
}
}
if (finished != null) b.put("isFinished", finished.booleanValue());
} catch (JSONException ignored) {
}
call(c, "PATCH", "/api/me/progress/" + itemId + "/" + epId, b);
}
}
+134
View File
@@ -0,0 +1,134 @@
package com.miniabs;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.json.JSONArray;
import org.json.JSONObject;
import android.content.Context;
import android.content.SharedPreferences;
/**
* Shared in-memory state: listening progress from the server, plus positions
* saved on the phone that still have to reach the server (offline listening).
*/
final class App {
private App() {
}
/** episodeId -> {currentTime, duration, finished (0/1)} */
private static final HashMap<String, double[]> progress = new HashMap<String, double[]>();
private static long progressLoadedAt;
static synchronized double[] progress(String epId) {
return progress.get(epId);
}
static synchronized void setProgress(String epId, double cur, double dur, boolean finished) {
progress.put(epId, new double[]{cur, dur, finished ? 1 : 0});
}
static synchronized boolean progressFresh() {
return System.currentTimeMillis() - progressLoadedAt < 60000;
}
/** Forces the next screen to re-read progress from the server, keeping what we have meanwhile. */
static synchronized void invalidate() {
progressLoadedAt = 0;
}
static synchronized void clear() {
progress.clear();
progressLoadedAt = 0;
}
/** Pulls the user's progress for every episode they've touched. */
static void loadProgress(Context c) throws Exception {
JSONObject me = Api.me(c);
JSONArray list = me.optJSONArray("mediaProgress");
HashMap<String, double[]> fresh = new HashMap<String, double[]>();
if (list != null) {
for (int i = 0; i < list.length(); i++) {
JSONObject p = list.optJSONObject(i);
if (p == null) continue;
String ep = Util.str(p, "episodeId");
if (ep.length() == 0) continue;
fresh.put(ep, new double[]{p.optDouble("currentTime", 0), p.optDouble("duration", 0),
p.optBoolean("isFinished", false) ? 1 : 0});
}
}
// Positions saved offline are newer than what the server knows.
SharedPreferences sp = local(c);
for (Map.Entry<String, ?> e : sp.getAll().entrySet()) {
String[] v = String.valueOf(e.getValue()).split("\\|");
if (v.length >= 4 && "1".equals(v[3])) {
fresh.put(e.getKey(), new double[]{parse(v[1]), parse(v[2]), 0});
}
}
synchronized (App.class) {
progress.clear();
progress.putAll(fresh);
progressLoadedAt = System.currentTimeMillis();
}
}
// ------------------------------------------------------------- local positions
private static SharedPreferences local(Context c) {
return c.getApplicationContext().getSharedPreferences("positions", Context.MODE_PRIVATE);
}
private static double parse(String s) {
try {
return Double.parseDouble(s);
} catch (Exception e) {
return 0;
}
}
/** Remembers a position on the phone. pending = not yet confirmed by the server. */
static void saveLocal(Context c, Episode e, double cur, double dur, boolean pending) {
local(c).edit().putString(e.id, e.itemId + "|" + cur + "|" + dur + "|" + (pending ? "1" : "0")).commit();
}
static double localPosition(Context c, String epId) {
String[] v = local(c).getString(epId, "").split("\\|");
return v.length >= 2 ? parse(v[1]) : -1;
}
static boolean localPending(Context c, String epId) {
String[] v = local(c).getString(epId, "").split("\\|");
return v.length >= 4 && "1".equals(v[3]);
}
static void forgetLocal(Context c, String epId) {
local(c).edit().remove(epId).commit();
}
/** Sends positions saved while offline. Returns how many reached the server. */
static int flushPending(Context c) {
SharedPreferences sp = local(c);
List<String[]> todo = new ArrayList<String[]>();
for (Map.Entry<String, ?> e : sp.getAll().entrySet()) {
String[] v = String.valueOf(e.getValue()).split("\\|");
if (v.length >= 4 && "1".equals(v[3])) todo.add(new String[]{e.getKey(), v[0], v[1], v[2]});
}
int sent = 0;
for (String[] t : todo) {
try {
double cur = parse(t[2]), dur = parse(t[3]);
boolean done = dur > 0 && cur >= dur - 1; // "played" marked while offline
Api.setProgress(c, t[1], t[0], cur, dur, done ? Boolean.TRUE : null);
// keep the position for downloaded files, but mark it as synced
sp.edit().putString(t[0], t[1] + "|" + cur + "|" + dur + "|0").commit();
sent++;
} catch (Exception ex) {
break; // still offline; try again later
}
}
return sent;
}
}
+237
View File
@@ -0,0 +1,237 @@
package com.miniabs;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Handler;
import android.view.ContextMenu;
import android.view.MenuItem;
import android.view.View;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
/** Shared bits: the mini player bar, a 1-second UI tick, sign-in errors and episode actions. */
abstract class BaseActivity extends Activity {
protected final Handler handler = new Handler();
private boolean ticking;
private View mini;
private TextView miniTitle, miniSub;
private ImageButton miniPlay;
private ProgressBar miniProgress;
private final Runnable ticker = new Runnable() {
public void run() {
onTick();
if (ticking) handler.postDelayed(this, 1000);
}
};
@Override
protected void onResume() {
super.onResume();
ticking = true;
handler.post(ticker);
}
@Override
protected void onPause() {
ticking = false;
handler.removeCallbacks(ticker);
super.onPause();
}
protected void onTick() {
updateMiniBar();
}
protected void toast(String s) {
Toast.makeText(this, s, s.length() > 60 ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show();
}
/** Shows an error; sends the user to the login screen if their sign-in expired. */
protected void showError(Exception e) {
if (e instanceof Api.AuthException) {
Prefs.get(this).logout();
App.clear();
HomeActivity.clearCache();
startActivity(new Intent(this, LoginActivity.class)
.putExtra("message", e.getMessage())
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
finish();
return;
}
toast(Util.describe(e));
}
// ------------------------------------------------------------ mini player
protected void setupMiniBar() {
mini = findViewById(R.id.mini_bar);
if (mini == null) return;
miniTitle = (TextView) findViewById(R.id.mini_title);
miniSub = (TextView) findViewById(R.id.mini_sub);
miniPlay = (ImageButton) findViewById(R.id.mini_play);
miniProgress = (ProgressBar) findViewById(R.id.mini_progress);
findViewById(R.id.mini_info).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
startActivity(new Intent(BaseActivity.this, PlayerActivity.class));
}
});
miniPlay.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null) {
PlayerService.send(BaseActivity.this, PlayerService.A_TOGGLE);
} else {
Episode last = Prefs.get(BaseActivity.this).lastEpisode();
if (last != null) PlayerService.play(BaseActivity.this, last);
}
handler.postDelayed(new Runnable() {
public void run() {
updateMiniBar();
}
}, 300);
}
});
}
protected void updateMiniBar() {
if (mini == null) return;
PlayerService ps = PlayerService.get();
Episode e = ps != null ? ps.episode() : null;
if (e == null) e = Prefs.get(this).lastEpisode();
if (e == null) {
mini.setVisibility(View.GONE);
return;
}
mini.setVisibility(View.VISIBLE);
miniTitle.setText(e.title);
boolean playing = ps != null && ps.episode() != null && ps.isPlaying();
miniPlay.setImageResource(playing ? R.drawable.ic_pause_small : R.drawable.ic_play_small);
int pos, dur;
String sub;
if (ps != null && ps.episode() != null) {
pos = ps.positionMs();
dur = ps.durationMs();
int st = ps.state();
if (st == PlayerService.LOADING || st == PlayerService.ERROR) sub = ps.status();
else sub = Util.join(e.show, dur > 0 ? Util.clock(dur - pos) + " left" : "");
} else {
double[] p = App.progress(e.id);
double cur = p != null ? p[0] : Math.max(0, App.localPosition(this, e.id));
pos = (int) (cur * 1000);
dur = (int) (e.duration * 1000);
sub = Util.join(e.show, "Paused");
}
miniSub.setText(sub);
miniProgress.setProgress(dur > 0 ? (int) (1000L * pos / dur) : 0);
}
// ------------------------------------------------------------ episode actions
protected static final int M_PLAY = 1, M_DOWNLOAD = 2, M_DELETE = 3, M_PLAYED = 4, M_UNPLAYED = 5,
M_SHOW = 6, M_CANCEL = 7;
protected void episodeMenu(ContextMenu menu, Episode e, boolean offerShow) {
menu.setHeaderTitle(e.title);
menu.add(0, M_PLAY, 0, "Play");
if (DownloadService.busyWith(e.id)) menu.add(0, M_CANCEL, 0, "Cancel download");
else if (Downloads.has(this, e.id)) menu.add(0, M_DELETE, 0, "Delete download");
else menu.add(0, M_DOWNLOAD, 0, "Download");
double[] p = App.progress(e.id);
if (p != null && p[2] > 0) menu.add(0, M_UNPLAYED, 0, "Mark as unplayed");
else menu.add(0, M_PLAYED, 0, "Mark as played");
if (offerShow) menu.add(0, M_SHOW, 0, "Go to show");
}
/** Returns true if handled. onChanged runs on the UI thread after the list should refresh. */
protected boolean episodeAction(int id, final Episode e, final Runnable onChanged) {
switch (id) {
case M_PLAY:
playEpisode(e);
return true;
case M_DOWNLOAD:
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
new AlertDialog.Builder(this)
.setMessage("You're not on Wi-Fi. Download over mobile data this time?")
.setPositiveButton("Download", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
Prefs.get(BaseActivity.this).sp.edit().putBoolean("wifi_only", false).commit();
DownloadService.enqueue(BaseActivity.this, e);
toast("Downloading. Wi-Fi-only is now off (change it in Settings).");
if (onChanged != null) onChanged.run();
}
})
.setNegativeButton("Cancel", null)
.show();
} else {
DownloadService.enqueue(this, e);
toast("Downloading…");
}
if (onChanged != null) onChanged.run();
return true;
case M_CANCEL:
DownloadService.cancel(e.id);
toast("Download cancelled");
if (onChanged != null) onChanged.run();
return true;
case M_DELETE:
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null && ps.episode().id.equals(e.id) && ps.isLocal()) {
ps.stop();
}
Downloads.delete(this, e);
toast("Download deleted");
if (onChanged != null) onChanged.run();
return true;
case M_PLAYED:
case M_UNPLAYED:
setPlayed(e, id == M_PLAYED, onChanged);
return true;
case M_SHOW:
startActivity(new Intent(this, PodcastActivity.class)
.putExtra("itemId", e.itemId).putExtra("title", e.show));
return true;
default:
return false;
}
}
protected void playEpisode(Episode e) {
PlayerService.play(this, e);
startActivity(new Intent(this, PlayerActivity.class).putExtra("ep", e.toJson().toString()));
}
private void setPlayed(final Episode e, final boolean played, final Runnable onChanged) {
final double dur = e.duration;
final double[] before = App.progress(e.id);
App.setProgress(e.id, played ? dur : 0, dur, played);
if (onChanged != null) onChanged.run();
new Job<Void>() {
Void work() throws Exception {
Api.setProgress(BaseActivity.this, e.itemId, e.id, played ? dur : 0, dur, Boolean.valueOf(played));
App.forgetLocal(BaseActivity.this, e.id);
return null;
}
void done(Void r) {
toast(played ? "Marked as played" : "Marked as unplayed");
}
void failed(Exception ex) {
if (before != null) App.setProgress(e.id, before[0], before[1], before[2] > 0);
else App.setProgress(e.id, 0, dur, false);
if (onChanged != null) onChanged.run();
showError(ex);
}
}.start();
}
protected boolean onEpisodeContextItem(MenuItem item, Episode e, Runnable onChanged) {
return e != null && episodeAction(item.getItemId(), e, onChanged);
}
}
+119
View File
@@ -0,0 +1,119 @@
package com.miniabs;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Handler;
import android.widget.ImageView;
/** Loads small JPEG covers with a tiny memory cache and a disk cache (saves data on repeat visits). */
final class Covers {
private Covers() {
}
private static final int MAX_ENTRIES = 30;
private static final Handler ui = new Handler(android.os.Looper.getMainLooper());
private static final ExecutorService pool = Executors.newFixedThreadPool(2);
private static final Map<String, Bitmap> cache = new LinkedHashMap<String, Bitmap>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Bitmap> eldest) {
return size() > MAX_ENTRIES;
}
};
/** px: size in physical pixels (covers are requested at roughly that size). */
static void load(final Context c, final ImageView view, final String itemId, int px) {
if (!Prefs.get(c).covers() || itemId == null || itemId.length() == 0) {
view.setImageDrawable(null);
view.setVisibility(android.view.View.GONE);
return;
}
view.setVisibility(android.view.View.VISIBLE);
final int size = px <= 64 ? 64 : 128;
final String key = itemId + "_" + size;
view.setTag(key);
Bitmap b;
synchronized (cache) {
b = cache.get(key);
}
if (b != null) {
view.setImageBitmap(b);
return;
}
view.setImageDrawable(null);
final Context app = c.getApplicationContext();
pool.execute(new Runnable() {
public void run() {
final Bitmap bmp = fetch(app, itemId, size, key);
if (bmp == null) return;
synchronized (cache) {
cache.put(key, bmp);
}
ui.post(new Runnable() {
public void run() {
if (key.equals(view.getTag())) view.setImageBitmap(bmp);
}
});
}
});
}
private static Bitmap fetch(Context c, String itemId, int size, String key) {
File dir = new File(c.getCacheDir(), "covers");
File f = new File(dir, key + ".jpg");
try {
if (!f.exists()) {
if (!Util.online(c)) return null;
dir.mkdirs();
HttpURLConnection conn = Api.open(c, Api.coverUrl(c, itemId, size), "GET");
try {
if (Api.responseCode(conn) != 200) return null;
File tmp = new File(dir, key + ".tmp");
InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(tmp);
try {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
} finally {
out.close();
in.close();
}
tmp.renameTo(f);
} finally {
conn.disconnect();
}
}
BitmapFactory.Options o = new BitmapFactory.Options();
o.inJustDecodeBounds = true;
BitmapFactory.decodeFile(f.getPath(), o);
int sample = 1;
while (o.outWidth / (sample * 2) >= size) sample *= 2;
o = new BitmapFactory.Options();
o.inSampleSize = sample;
o.inPreferredConfig = Bitmap.Config.RGB_565;
Bitmap b = BitmapFactory.decodeFile(f.getPath(), o);
if (b == null) f.delete();
return b;
} catch (Throwable t) { // includes OutOfMemoryError on very small heaps
return null;
}
}
static void clearMemory() {
synchronized (cache) {
cache.clear();
}
}
}
+230
View File
@@ -0,0 +1,230 @@
package com.miniabs;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONObject;
import android.app.IntentService;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.wifi.WifiManager;
import android.os.PowerManager;
/** Downloads episodes one at a time to the SD card, resuming partial files. */
public class DownloadService extends IntentService {
private static final int NOTIF_ID = 2;
private static final List<Episode> queue = new ArrayList<Episode>();
static volatile Episode current;
static volatile int percent;
static volatile String lastError;
private static volatile String cancelId;
public DownloadService() {
super("MiniABS downloads");
}
static void enqueue(Context c, Episode e) {
synchronized (queue) {
if (current != null && current.id.equals(e.id)) return;
for (Episode q : queue) if (q.id.equals(e.id)) return;
queue.add(e);
}
lastError = null;
c.startService(new Intent(c, DownloadService.class).putExtra("ep", e.toJson().toString()));
}
static void cancel(String epId) {
synchronized (queue) {
for (int i = queue.size() - 1; i >= 0; i--) if (queue.get(i).id.equals(epId)) queue.remove(i);
}
Episode cur = current;
if (cur != null && cur.id.equals(epId)) cancelId = epId;
}
static List<Episode> queued() {
synchronized (queue) {
return new ArrayList<Episode>(queue);
}
}
static boolean busyWith(String epId) {
Episode cur = current;
if (cur != null && cur.id.equals(epId)) return true;
synchronized (queue) {
for (Episode q : queue) if (q.id.equals(epId)) return true;
}
return false;
}
@Override
protected void onHandleIntent(Intent intent) {
Episode e;
try {
e = Episode.fromStored(new JSONObject(intent.getStringExtra("ep")));
} catch (Exception ex) {
return;
}
synchronized (queue) {
boolean found = false;
for (int i = 0; i < queue.size(); i++) {
if (queue.get(i).id.equals(e.id)) {
queue.remove(i);
found = true;
break;
}
}
if (!found) return; // cancelled while waiting
current = e;
}
percent = 0;
cancelId = null;
PowerManager.WakeLock wake = ((PowerManager) getSystemService(POWER_SERVICE))
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MiniABS download");
WifiManager.WifiLock wifi = ((WifiManager) getSystemService(WIFI_SERVICE))
.createWifiLock(WifiManager.WIFI_MODE_FULL, "MiniABS download");
wake.acquire();
wifi.acquire();
try {
download(e);
if (e.id.equals(cancelId)) {
Downloads.delete(this, e);
}
} catch (Exception ex) {
if (!e.id.equals(cancelId)) {
lastError = "Download failed: " + Util.describe(ex);
notifyDone(e.title, lastError);
}
} finally {
current = null;
cancelId = null;
stopForeground(true);
wifi.release();
wake.release();
}
}
private void download(Episode e) throws IOException {
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
throw new IOException("not on Wi-Fi (Settings: Download on Wi-Fi only)");
}
File dir = Downloads.dir(this);
if (Downloads.audio(this, e) != null) {
Downloads.saved(this, e);
return;
}
if (e.ino.length() == 0) throw new IOException("this episode has no audio file");
File part = new File(dir, e.id + ".part");
Notification n = progressNotification(e, 0);
startForeground(NOTIF_ID, n);
Api.ensureFresh(this, 600);
HttpURLConnection conn = null;
int code = 0;
long have = part.exists() ? part.length() : 0;
boolean refreshed = false;
String[] urls = Api.downloadUrls(this, e);
for (int i = 0; i < urls.length; i++) {
conn = Api.open(this, urls[i], "GET");
conn.setReadTimeout(60000);
if (have > 0) conn.setRequestProperty("Range", "bytes=" + have + "-");
code = Api.responseCode(conn);
if (code == 401 && !refreshed && Prefs.get(this).canRefresh()) {
conn.disconnect();
Api.refresh(this, true);
refreshed = true;
urls = Api.downloadUrls(this, e);
i--;
continue;
}
if (code == 200 || code == 206) break;
conn.disconnect();
if (code == 416 && have > 0) { // stale partial file: start over
part.delete();
have = 0;
i--;
continue;
}
if (code != 404 && code != 403) break;
}
if (code != 200 && code != 206) {
if (code == 401) throw new Api.AuthException("sign-in expired; log in again");
if (code == 403) throw new IOException("your account isn't allowed to download");
throw new Api.ApiException(code, "");
}
try {
boolean append = code == 206 && have > 0;
if (!append) have = 0;
long len = conn.getContentLength();
long total = len > 0 ? have + len : (e.size > 0 ? e.size : -1);
InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(part, append);
try {
byte[] buf = new byte[16384];
long done = have, lastUi = 0;
int r;
while ((r = in.read(buf)) > 0) {
if (e.id.equals(cancelId)) return;
out.write(buf, 0, r);
done += r;
long now = System.currentTimeMillis();
if (now - lastUi > 1500) {
lastUi = now;
percent = total > 0 ? (int) (done * 100 / total) : 0;
n = progressNotification(e, percent);
nm().notify(NOTIF_ID, n);
}
}
} finally {
out.close();
in.close();
}
} finally {
conn.disconnect();
}
if (e.id.equals(cancelId)) return;
File dest = new File(dir, e.id + e.ext);
if (!part.renameTo(dest)) throw new IOException("couldn't save the file");
Downloads.saved(this, e);
percent = 100;
}
private NotificationManager nm() {
return (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
}
private PendingIntent openApp() {
Intent i = new Intent(this, HomeActivity.class).putExtra("tab", HomeActivity.TAB_SAVED)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(this, 1, i, PendingIntent.FLAG_UPDATE_CURRENT);
}
private Notification progressNotification(Episode e, int pct) {
Notification n = new Notification(android.R.drawable.stat_sys_download, null, System.currentTimeMillis());
n.flags |= Notification.FLAG_ONGOING_EVENT | Notification.FLAG_ONLY_ALERT_ONCE;
int left;
synchronized (queue) {
left = queue.size();
}
String text = "Downloading " + pct + "%" + (left > 0 ? " · " + left + " more queued" : "");
n.setLatestEventInfo(this, e.title, text, openApp());
return n;
}
private void notifyDone(String title, String text) {
Notification n = new Notification(android.R.drawable.stat_notify_error, text, System.currentTimeMillis());
n.flags |= Notification.FLAG_AUTO_CANCEL;
n.setLatestEventInfo(this, title, text, openApp());
nm().notify(3, n);
}
}
+143
View File
@@ -0,0 +1,143 @@
package com.miniabs;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import org.json.JSONObject;
import android.content.Context;
import android.os.Environment;
/** Downloaded episodes live on the SD card: <id>.<ext> plus <id>.json with the episode details. */
final class Downloads {
private Downloads() {
}
private static HashSet<String> ids;
static File dir(Context c) throws IOException {
if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
throw new IOException("No SD card available. Insert or unmount it from USB to download.");
}
File d = c.getExternalFilesDir(Environment.DIRECTORY_PODCASTS);
if (d == null) throw new IOException("Can't use the SD card.");
if (!d.exists() && !d.mkdirs()) throw new IOException("Can't create a folder on the SD card.");
File nomedia = new File(d, ".nomedia");
if (!nomedia.exists()) {
try {
nomedia.createNewFile();
} catch (IOException ignored) {
}
}
return d;
}
static File audio(Context c, Episode e) {
try {
File f = new File(dir(c), e.id + e.ext);
return f.exists() && f.length() > 0 ? f : null;
} catch (IOException ex) {
return null;
}
}
static synchronized boolean has(Context c, String epId) {
if (ids == null) scan(c);
return ids.contains(epId);
}
/** Reads the download folder and returns saved episodes, newest first. */
static synchronized List<Episode> list(Context c) {
List<Episode> out = new ArrayList<Episode>();
HashSet<String> found = new HashSet<String>();
try {
File[] files = dir(c).listFiles();
if (files != null) {
for (File f : files) {
if (!f.getName().endsWith(".json")) continue;
try {
Episode e = Episode.fromStored(new JSONObject(readFile(f)));
if (audio(c, e) != null) {
out.add(e);
found.add(e.id);
}
} catch (Exception ignored) {
}
}
}
} catch (IOException ignored) {
}
ids = found;
Collections.sort(out, new Comparator<Episode>() {
public int compare(Episode a, Episode b) {
return a.published == b.published ? 0 : (a.published > b.published ? -1 : 1);
}
});
return out;
}
static synchronized void scan(Context c) {
list(c);
}
static synchronized void saved(Context c, Episode e) throws IOException {
Writer w = new OutputStreamWriter(new FileOutputStream(new File(dir(c), e.id + ".json")), "UTF-8");
try {
w.write(e.toJson().toString());
} finally {
w.close();
}
if (ids == null) scan(c);
ids.add(e.id);
}
static synchronized void delete(Context c, Episode e) {
try {
File d = dir(c);
new File(d, e.id + e.ext).delete();
new File(d, e.id + ".json").delete();
new File(d, e.id + ".part").delete();
} catch (IOException ignored) {
}
if (ids != null) ids.remove(e.id);
App.forgetLocal(c, e.id);
}
static synchronized int deleteAll(Context c) {
int n = 0;
for (Episode e : list(c)) {
delete(c, e);
n++;
}
try {
File[] parts = dir(c).listFiles();
if (parts != null) for (File f : parts) if (f.getName().endsWith(".part")) f.delete();
} catch (IOException ignored) {
}
return n;
}
private static String readFile(File f) throws IOException {
Reader r = new InputStreamReader(new FileInputStream(f), "UTF-8");
try {
StringBuilder sb = new StringBuilder();
char[] b = new char[2048];
int n;
while ((n = r.read(b)) > 0) sb.append(b, 0, n);
return sb.toString();
} finally {
r.close();
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package com.miniabs;
import org.json.JSONException;
import org.json.JSONObject;
/** One podcast episode, trimmed down to what MiniABS needs. */
final class Episode {
String itemId = ""; // library item (the podcast) id
String id = ""; // episode id
String title = "";
String show = ""; // podcast title
String author = "";
String desc = "";
String ino = ""; // audio file inode, used for downloads
String ext = ".mp3";
String mime = "";
long published; // ms since epoch
double duration; // seconds
long size; // bytes
/** Builds an episode from an Audiobookshelf episode object. Returns null if it has no audio. */
static Episode fromApi(JSONObject e, String itemId, String show, String author) {
if (e == null) return null;
Episode ep = new Episode();
ep.id = Util.str(e, "id");
ep.itemId = itemId != null && itemId.length() > 0 ? itemId : Util.str(e, "libraryItemId");
ep.title = Util.str(e, "title");
ep.show = show == null ? "" : show;
ep.author = author == null ? "" : author;
ep.desc = Util.str(e, "description");
if (ep.desc.length() == 0) ep.desc = Util.str(e, "subtitle");
ep.published = e.optLong("publishedAt", 0);
JSONObject af = e.optJSONObject("audioFile");
if (af != null) {
ep.duration = af.optDouble("duration", 0);
ep.ino = Util.str(af, "ino");
ep.mime = Util.str(af, "mimeType");
JSONObject meta = af.optJSONObject("metadata");
if (meta != null) {
String x = Util.str(meta, "ext");
if (x.length() > 0) ep.ext = x;
ep.size = meta.optLong("size", 0);
}
}
if (ep.duration <= 0) ep.duration = e.optDouble("duration", 0);
if (ep.id.length() == 0 || ep.itemId.length() == 0 || af == null) return null;
ep.ext = safeExt(ep.ext);
// The podcast object (when the API sends it) carries the show name
JSONObject pod = e.optJSONObject("podcast");
if (pod != null && ep.show.length() == 0) {
JSONObject md = pod.optJSONObject("metadata");
if (md != null) {
ep.show = Util.str(md, "title");
ep.author = Util.str(md, "author");
}
}
return ep;
}
private static String safeExt(String x) {
x = x.toLowerCase();
if (!x.startsWith(".")) x = "." + x;
if (!x.matches("\\.[a-z0-9]{1,5}")) x = ".mp3";
return x;
}
JSONObject toJson() {
JSONObject o = new JSONObject();
try {
o.put("itemId", itemId).put("id", id).put("title", title).put("show", show)
.put("author", author).put("desc", desc).put("ino", ino).put("ext", ext)
.put("mime", mime).put("published", published).put("duration", duration)
.put("size", size);
} catch (JSONException ignored) {
}
return o;
}
static Episode fromStored(JSONObject o) {
Episode e = new Episode();
e.itemId = Util.str(o, "itemId");
e.id = Util.str(o, "id");
e.title = Util.str(o, "title");
e.show = Util.str(o, "show");
e.author = Util.str(o, "author");
e.desc = Util.str(o, "desc");
e.ino = Util.str(o, "ino");
e.ext = safeExt(o.optString("ext", ".mp3"));
e.mime = Util.str(o, "mime");
e.published = o.optLong("published", 0);
e.duration = o.optDouble("duration", 0);
e.size = o.optLong("size", 0);
return e;
}
}
+506
View File
@@ -0,0 +1,506 @@
package com.miniabs;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.view.ContextMenu;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView;
public class HomeActivity extends BaseActivity {
static final int TAB_LATEST = 0, TAB_SHOWS = 1, TAB_SAVED = 2;
private static final int PAGE = 20;
// Kept across screen changes so going back is instant on a slow phone.
private static List<String[]> podcastLibs;
private static List<Rows.Row> latestCache, showsCache;
private static int latestPage;
private static boolean latestMore;
private ListView list;
private TextView empty, library;
private ProgressBar loading;
private Rows adapter;
private TextView[] tabs;
private int tab;
private boolean loadingLatest, loadingShows;
private String latestError, showsError;
private String savedSignature = "";
private int ticks;
static void clearCache() {
podcastLibs = null;
latestCache = null;
showsCache = null;
latestPage = 0;
latestMore = false;
}
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
if (!Prefs.get(this).loggedIn()) {
startActivity(new Intent(this, LoginActivity.class));
finish();
return;
}
setContentView(R.layout.activity_home);
list = (ListView) findViewById(R.id.list);
empty = (TextView) findViewById(R.id.empty);
loading = (ProgressBar) findViewById(R.id.loading);
library = (TextView) findViewById(R.id.library);
adapter = new Rows(this);
list.setAdapter(adapter);
registerForContextMenu(list);
setupMiniBar();
tabs = new TextView[]{(TextView) findViewById(R.id.tab_latest), (TextView) findViewById(R.id.tab_shows),
(TextView) findViewById(R.id.tab_saved)};
for (int i = 0; i < tabs.length; i++) {
final int t = i;
tabs[i].setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
selectTab(t);
}
});
}
library.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
chooseLibrary();
}
});
findViewById(R.id.refresh).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
refresh();
}
});
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
public void onItemClick(AdapterView<?> parent, View view, int pos, long id) {
onRowClick(adapter.getItem(pos));
}
});
updateLibraryLabel();
int start = getIntent().getIntExtra("tab", Prefs.get(this).sp.getInt("tab", TAB_LATEST));
selectTab(start);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
if (intent.hasExtra("tab")) selectTab(intent.getIntExtra("tab", TAB_LATEST));
}
@Override
protected void onResume() {
super.onResume();
if (adapter == null) return;
if (!Prefs.get(this).loggedIn()) { // logged out from Settings
startActivity(new Intent(this, LoginActivity.class));
finish();
return;
}
updateLibraryLabel();
if (tab == TAB_SAVED) showSaved();
else if ((tab == TAB_LATEST && latestCache == null && !loadingLatest)
|| (tab == TAB_SHOWS && showsCache == null && !loadingShows)) selectTab(tab); // signed in again
else adapter.notifyDataSetChanged();
}
@Override
protected void onTick() {
super.onTick();
if (adapter == null) return;
ticks++;
if (tab == TAB_SAVED) {
Episode cur = DownloadService.current;
String sig = (cur == null ? "" : cur.id) + "/" + DownloadService.queued().size()
+ "/" + DownloadService.lastError;
if (!sig.equals(savedSignature)) showSaved();
else if (cur != null) adapter.notifyDataSetChanged();
} else if (ticks % 3 == 0) {
adapter.notifyDataSetChanged();
}
}
private void updateLibraryLabel() {
String name = Prefs.get(this).libraryName();
library.setText(name.length() > 0 ? name : "");
}
// ------------------------------------------------------------------ tabs
private void selectTab(int t) {
if (t < 0 || t > 2) t = TAB_LATEST;
tab = t;
Prefs.get(this).sp.edit().putInt("tab", t).commit();
for (int i = 0; i < tabs.length; i++) {
tabs[i].setSelected(i == t);
tabs[i].setTextColor(getResources().getColor(i == t ? R.color.text : R.color.sub));
}
adapter.showPodcastName = t != TAB_SHOWS;
if (t == TAB_LATEST) {
if (latestCache != null) show(latestCache, null);
else if (!loadingLatest) loadLatest(false);
else show(null, null);
} else if (t == TAB_SHOWS) {
if (showsCache != null) show(showsCache, null);
else if (!loadingShows) loadShows();
else show(null, null);
} else {
showSaved();
}
list.setSelection(0);
}
/** rows == null means "still loading". */
private void show(List<Rows.Row> rows, String emptyText) {
boolean busy = rows == null && ((tab == TAB_LATEST && loadingLatest) || (tab == TAB_SHOWS && loadingShows));
String err = tab == TAB_LATEST ? latestError : tab == TAB_SHOWS ? showsError : null;
adapter.set(rows == null ? new ArrayList<Rows.Row>() : rows);
loading.setVisibility(busy ? View.VISIBLE : View.GONE);
String msg = null;
if (!busy && (rows == null || rows.isEmpty())) msg = err != null ? err : emptyText;
empty.setText(msg == null ? "" : msg);
empty.setVisibility(msg == null ? View.GONE : View.VISIBLE);
}
private void refresh() {
App.invalidate();
Covers.clearMemory();
latestCache = null;
showsCache = null;
podcastLibs = null;
latestError = null;
showsError = null;
if (tab == TAB_SHOWS) loadShows();
else if (tab == TAB_LATEST) loadLatest(false);
else {
showSaved();
new Job<Integer>() {
Integer work() {
return App.flushPending(HomeActivity.this);
}
void done(Integer n) {
if (n > 0) toast("Synced offline progress");
}
}.start();
}
}
// ------------------------------------------------------------------ data
/** Picks a podcast library (the saved one if it still exists). Runs in the background. */
private String ensureLibrary() throws Exception {
Prefs p = Prefs.get(this);
if (podcastLibs == null) {
JSONArray libs = Api.libraries(this);
List<String[]> found = new ArrayList<String[]>();
for (int i = 0; i < libs.length(); i++) {
JSONObject l = libs.optJSONObject(i);
if (l != null && "podcast".equals(Util.str(l, "mediaType"))) {
found.add(new String[]{Util.str(l, "id"), Util.str(l, "name")});
}
}
if (found.isEmpty()) throw new IOException("This server has no podcast libraries.");
podcastLibs = found;
}
String id = p.libraryId();
for (String[] l : podcastLibs) {
if (l[0].equals(id)) {
if (!l[1].equals(p.libraryName())) p.setLibrary(l[0], l[1]);
return id;
}
}
String[] first = podcastLibs.get(0);
p.setLibrary(first[0], first[1]);
return first[0];
}
private void loadLatest(final boolean more) {
loadingLatest = true;
latestError = null;
if (!more && tab == TAB_LATEST) show(null, null);
final int page = more ? latestPage + 1 : 0;
new Job<List<Rows.Row>>() {
boolean hasMore;
List<Rows.Row> work() throws Exception {
String lib = ensureLibrary();
List<Rows.Row> rows = new ArrayList<Rows.Row>();
if (!more) {
App.flushPending(HomeActivity.this);
App.loadProgress(HomeActivity.this);
List<Rows.Row> cont = new ArrayList<Rows.Row>();
try {
JSONArray items = Api.itemsInProgress(HomeActivity.this).optJSONArray("libraryItems");
for (int i = 0; items != null && i < items.length(); i++) {
JSONObject it = items.optJSONObject(i);
if (it == null || !lib.equals(Util.str(it, "libraryId"))) continue;
JSONObject media = it.optJSONObject("media");
JSONObject md = media == null ? null : media.optJSONObject("metadata");
Episode e = Episode.fromApi(it.optJSONObject("recentEpisode"), Util.str(it, "id"),
Util.str(md, "title"), Util.str(md, "author"));
if (e == null) continue;
double[] p = App.progress(e.id);
if (p != null && p[2] > 0) continue;
cont.add(Rows.Row.episode(e));
if (cont.size() >= 5) break;
}
} catch (Api.AuthException ae) {
throw ae;
} catch (Exception ignored) {
// older servers: just skip this section
}
if (!cont.isEmpty()) {
rows.add(Rows.Row.header("CONTINUE LISTENING"));
rows.addAll(cont);
}
rows.add(Rows.Row.header("LATEST EPISODES"));
}
JSONArray eps = Api.recentEpisodes(HomeActivity.this, lib, page, PAGE).optJSONArray("episodes");
int n = eps == null ? 0 : eps.length();
for (int i = 0; i < n; i++) {
Episode e = Episode.fromApi(eps.optJSONObject(i), null, null, null);
if (e != null) rows.add(Rows.Row.episode(e));
}
hasMore = n >= PAGE;
return rows;
}
void done(List<Rows.Row> rows) {
loadingLatest = false;
if (isFinishing()) return;
updateLibraryLabel();
if (more && latestCache != null) {
int last = latestCache.size() - 1;
if (last >= 0 && latestCache.get(last).type == Rows.MORE) latestCache.remove(last);
latestCache.addAll(rows);
} else {
latestCache = rows;
}
latestPage = page;
latestMore = hasMore;
if (hasMore) latestCache.add(Rows.Row.more("Load more"));
if (tab == TAB_LATEST) show(latestCache, "No episodes yet.");
}
void failed(Exception e) {
loadingLatest = false;
if (isFinishing()) return;
if (e instanceof Api.AuthException) {
showError(e);
return;
}
if (more) {
toast(Util.describe(e));
if (latestCache != null && !latestCache.isEmpty()) {
latestCache.get(latestCache.size() - 1).text = "Load more";
adapter.notifyDataSetChanged();
}
return;
}
latestError = Util.describe(e) + "\n\nTap refresh to try again.";
if (tab == TAB_LATEST) offlineFallback(e);
}
}.start();
}
private void loadShows() {
loadingShows = true;
showsError = null;
if (tab == TAB_SHOWS) show(null, null);
new Job<List<Rows.Row>>() {
List<Rows.Row> work() throws Exception {
String lib = ensureLibrary();
List<Rows.Row> rows = new ArrayList<Rows.Row>();
for (int page = 0; page < 20; page++) {
JSONObject r = Api.libraryItems(HomeActivity.this, lib, page, 100);
JSONArray res = r.optJSONArray("results");
int n = res == null ? 0 : res.length();
for (int i = 0; i < n; i++) {
JSONObject it = res.optJSONObject(i);
if (it == null) continue;
JSONObject media = it.optJSONObject("media");
JSONObject md = media == null ? null : media.optJSONObject("metadata");
Rows.Show s = new Rows.Show();
s.id = Util.str(it, "id");
s.title = Util.str(md, "title");
s.author = Util.str(md, "author");
s.episodes = media == null ? 0 : media.optInt("numEpisodes", 0);
if (s.episodes == 0 && media != null && media.optJSONArray("episodes") != null) {
s.episodes = media.optJSONArray("episodes").length();
}
rows.add(Rows.Row.show(s));
}
int total = r.optInt("total", 0);
if (n < 100 || rows.size() >= total) break;
}
return rows;
}
void done(List<Rows.Row> rows) {
loadingShows = false;
if (isFinishing()) return;
updateLibraryLabel();
showsCache = rows;
if (tab == TAB_SHOWS) show(rows, "No podcasts in this library.");
}
void failed(Exception e) {
loadingShows = false;
if (isFinishing()) return;
if (e instanceof Api.AuthException) {
showError(e);
return;
}
showsError = Util.describe(e) + "\n\nTap refresh to try again.";
if (tab == TAB_SHOWS) offlineFallback(e);
}
}.start();
}
/** When the server can't be reached but episodes are saved, show those instead. */
private void offlineFallback(Exception e) {
if (!(e instanceof Api.ApiException) && !Downloads.list(this).isEmpty()) {
toast("Can't reach the server. Showing saved episodes.");
selectTab(TAB_SAVED);
} else {
show(null, null);
}
}
private void showSaved() {
List<Rows.Row> rows = new ArrayList<Rows.Row>();
Episode cur = DownloadService.current;
List<Episode> queued = DownloadService.queued();
savedSignature = (cur == null ? "" : cur.id) + "/" + queued.size() + "/" + DownloadService.lastError;
if (DownloadService.lastError != null) rows.add(Rows.Row.header(DownloadService.lastError));
if (cur != null || !queued.isEmpty()) {
rows.add(Rows.Row.header("DOWNLOADING"));
if (cur != null) rows.add(Rows.Row.episode(cur));
for (Episode q : queued) rows.add(Rows.Row.episode(q));
}
List<Episode> saved = Downloads.list(this);
if (!saved.isEmpty()) {
rows.add(Rows.Row.header("SAVED ON SD CARD (" + saved.size() + ")"));
for (Episode e : saved) rows.add(Rows.Row.episode(e));
}
if (rows.isEmpty()) {
show(rows, "Nothing saved yet.\n\nLong-press an episode and choose Download to listen offline.");
} else {
show(rows, null);
}
}
// ------------------------------------------------------------------ interaction
private void onRowClick(Rows.Row r) {
if (r == null) return;
switch (r.type) {
case Rows.EPISODE:
playEpisode(r.ep);
break;
case Rows.SHOW:
startActivity(new Intent(this, PodcastActivity.class)
.putExtra("itemId", r.show.id).putExtra("title", r.show.title)
.putExtra("author", r.show.author));
break;
case Rows.MORE:
if (!loadingLatest && latestMore) {
r.text = "Loading…";
adapter.notifyDataSetChanged();
loadLatest(true);
}
break;
default:
break;
}
}
private Episode menuEpisode;
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo info) {
AdapterView.AdapterContextMenuInfo mi = (AdapterView.AdapterContextMenuInfo) info;
Rows.Row r = adapter.getItem(mi.position);
if (r.type != Rows.EPISODE) return;
menuEpisode = r.ep;
episodeMenu(menu, r.ep, true);
}
@Override
public boolean onContextItemSelected(MenuItem item) {
return onEpisodeContextItem(item, menuEpisode, new Runnable() {
public void run() {
if (tab == TAB_SAVED) showSaved();
else adapter.notifyDataSetChanged();
}
}) || super.onContextItemSelected(item);
}
private void chooseLibrary() {
if (podcastLibs == null || podcastLibs.size() < 2) {
toast(podcastLibs == null ? "Still loading libraries…" : "This is your only podcast library.");
return;
}
String[] names = new String[podcastLibs.size()];
for (int i = 0; i < names.length; i++) names[i] = podcastLibs.get(i)[1];
new AlertDialog.Builder(this).setTitle("Podcast library")
.setItems(names, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int which) {
String[] l = podcastLibs.get(which);
Prefs.get(HomeActivity.this).setLibrary(l[0], l[1]);
updateLibraryLabel();
latestCache = null;
showsCache = null;
selectTab(tab == TAB_SAVED ? TAB_LATEST : tab);
}
}).show();
}
// ------------------------------------------------------------------ menu key
@Override
public boolean onCreateOptionsMenu(Menu menu) {
menu.add(0, 1, 0, "Refresh").setIcon(android.R.drawable.ic_menu_rotate);
menu.add(0, 2, 0, "Library").setIcon(android.R.drawable.ic_menu_agenda);
menu.add(0, 3, 0, "Now playing").setIcon(android.R.drawable.ic_media_play);
menu.add(0, 4, 0, "Settings").setIcon(android.R.drawable.ic_menu_preferences);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case 1:
refresh();
return true;
case 2:
chooseLibrary();
return true;
case 3:
startActivity(new Intent(this, PlayerActivity.class));
return true;
case 4:
startActivity(new Intent(this, SettingsActivity.class));
return true;
default:
return super.onOptionsItemSelected(item);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package com.miniabs;
import android.os.AsyncTask;
/** Background work with the result (or error) delivered on the UI thread. */
abstract class Job<T> extends AsyncTask<Void, Void, T> {
private Exception error;
abstract T work() throws Exception;
abstract void done(T result);
void failed(Exception e) {
}
@Override
protected final T doInBackground(Void... v) {
try {
return work();
} catch (Exception e) {
error = e;
return null;
}
}
@Override
protected final void onPostExecute(T result) {
if (error != null) failed(error);
else done(result);
}
Job<T> start() {
execute();
return this;
}
}
+252
View File
@@ -0,0 +1,252 @@
package com.miniabs;
import java.io.IOException;
import java.io.Reader;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Small streaming JSON parser that builds org.json objects straight from the
* socket. Very long strings (episode show notes) are cut short and bulky keys
* are dropped while reading, so a podcast with hundreds of episodes still fits
* in the Galaxy Mini's small heap.
*/
final class Json {
/** Keys never kept, at any depth. */
private static final String[] ALWAYS_SKIP = {"chapters", "libraryFiles", "metaTags", "rssFeed"};
private final Reader in;
private final char[] buf = new char[8192];
private int pos, len;
private final int maxString;
private final String[] deepSkip;
private int depth;
private Json(Reader in, int maxString, String[] deepSkip) {
this.in = in;
this.maxString = maxString;
this.deepSkip = deepSkip;
}
static Object parse(Reader r) throws IOException {
return parse(r, 2000, null);
}
/**
* @param maxString longest string kept (longer ones end with an ellipsis)
* @param deepSkip keys dropped inside objects nested three or more levels deep
*/
static Object parse(Reader r, int maxString, String[] deepSkip) throws IOException {
Json j = new Json(r, maxString, deepSkip);
int c = j.skipWs();
if (c == -1) return null;
return j.value(c);
}
private int read() throws IOException {
if (pos >= len) {
len = in.read(buf, 0, buf.length);
pos = 0;
if (len <= 0) {
len = 0;
return -1;
}
}
return buf[pos++];
}
private void unread() {
pos--;
}
private int skipWs() throws IOException {
int c;
do {
c = read();
} while (c == ' ' || c == '\n' || c == '\r' || c == '\t');
return c;
}
private Object value(int c) throws IOException {
switch (c) {
case '{':
return object();
case '[':
return array();
case '"':
return string(maxString);
case 't':
expect("rue");
return Boolean.TRUE;
case 'f':
expect("alse");
return Boolean.FALSE;
case 'n':
expect("ull");
return JSONObject.NULL;
default:
if (c == '-' || (c >= '0' && c <= '9')) return number(c);
throw new IOException("Unexpected reply from server");
}
}
private void expect(String rest) throws IOException {
for (int i = 0; i < rest.length(); i++) {
if (read() != rest.charAt(i)) throw new IOException("Unexpected reply from server");
}
}
private Object number(int first) throws IOException {
StringBuilder sb = new StringBuilder(16);
sb.append((char) first);
boolean dbl = false;
int c;
while (true) {
c = read();
if ((c >= '0' && c <= '9') || c == '-' || c == '+') {
sb.append((char) c);
} else if (c == '.' || c == 'e' || c == 'E') {
dbl = true;
sb.append((char) c);
} else {
break;
}
}
if (c != -1) unread();
String s = sb.toString();
try {
if (!dbl) {
long l = Long.parseLong(s);
if (l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE) return Integer.valueOf((int) l);
return Long.valueOf(l);
}
return Double.valueOf(s);
} catch (NumberFormatException e) {
try {
return Double.valueOf(s);
} catch (NumberFormatException e2) {
return Integer.valueOf(0);
}
}
}
private boolean skipKey(String key) {
for (String k : ALWAYS_SKIP) if (k.equals(key)) return true;
if (deepSkip != null && depth >= 3) {
for (String k : deepSkip) if (k.equals(key)) return true;
}
return false;
}
private JSONObject object() throws IOException {
depth++;
JSONObject o = new JSONObject();
int c = skipWs();
if (c == '}') {
depth--;
return o;
}
while (true) {
if (c != '"') throw new IOException("Unexpected reply from server");
String key = string(512);
c = skipWs();
if (c != ':') throw new IOException("Unexpected reply from server");
c = skipWs();
if (skipKey(key)) {
skipValue(c);
} else {
Object v = value(c);
try {
o.put(key, v);
} catch (JSONException ignored) {
// NaN or similar; drop it
}
}
c = skipWs();
if (c == ',') {
c = skipWs();
continue;
}
if (c == '}') {
depth--;
return o;
}
throw new IOException("Unexpected reply from server");
}
}
private JSONArray array() throws IOException {
JSONArray a = new JSONArray();
int c = skipWs();
if (c == ']') return a;
while (true) {
a.put(value(c));
c = skipWs();
if (c == ',') {
c = skipWs();
continue;
}
if (c == ']') return a;
throw new IOException("Unexpected reply from server");
}
}
private void skipValue(int c) throws IOException {
if (c == '"') {
string(0);
return;
}
if (c == '{' || c == '[') {
int d = 1;
while (d > 0) {
int ch = read();
if (ch == -1) throw new IOException("Reply was cut off");
if (ch == '"') string(0);
else if (ch == '{' || ch == '[') d++;
else if (ch == '}' || ch == ']') d--;
}
return;
}
value(c);
}
private String string(int max) throws IOException {
StringBuilder sb = new StringBuilder(max > 0 ? Math.min(max, 32) : 0);
boolean cut = false;
while (true) {
int c = read();
if (c == -1) throw new IOException("Reply was cut off");
if (c == '"') break;
if (c == '\\') {
c = read();
switch (c) {
case 'n': c = '\n'; break;
case 't': c = '\t'; break;
case 'r': c = '\r'; break;
case 'b': c = '\b'; break;
case 'f': c = '\f'; break;
case 'u': {
int v = 0;
for (int i = 0; i < 4; i++) {
int d = Character.digit(read(), 16);
if (d < 0) throw new IOException("Unexpected reply from server");
v = v * 16 + d;
}
c = v;
break;
}
case -1:
throw new IOException("Reply was cut off");
default:
break; // \" \\ \/
}
}
if (sb.length() < max) sb.append((char) c);
else cut = true;
}
if (cut && max > 0) sb.append('…');
return sb.toString();
}
}
+117
View File
@@ -0,0 +1,117 @@
package com.miniabs;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.text.InputType;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.EditText;
import android.widget.TextView;
public class LoginActivity extends Activity {
private EditText server, user, pass;
private CheckBox useKey, trust;
private TextView status, userLabel, passLabel;
private Button connect;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setContentView(R.layout.activity_login);
server = (EditText) findViewById(R.id.server);
user = (EditText) findViewById(R.id.user);
pass = (EditText) findViewById(R.id.pass);
useKey = (CheckBox) findViewById(R.id.use_key);
trust = (CheckBox) findViewById(R.id.trust);
status = (TextView) findViewById(R.id.status);
userLabel = (TextView) findViewById(R.id.user_label);
passLabel = (TextView) findViewById(R.id.pass_label);
connect = (Button) findViewById(R.id.connect);
Prefs p = Prefs.get(this);
server.setText(p.server());
user.setText(p.username());
trust.setChecked(p.trustAll());
String msg = getIntent().getStringExtra("message");
if (msg != null) status.setText(msg);
useKey.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
public void onCheckedChanged(CompoundButton v, boolean on) {
int vis = on ? View.GONE : View.VISIBLE;
user.setVisibility(vis);
userLabel.setVisibility(vis);
passLabel.setText(on ? "API key" : "Password");
pass.setInputType(on ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
: InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
pass.setText("");
}
});
connect.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
go();
}
});
}
static String normalize(String s) {
s = s.trim();
while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
if (s.endsWith("/login")) s = s.substring(0, s.length() - 6);
if (s.length() > 0 && !s.startsWith("http://") && !s.startsWith("https://")) s = "http://" + s;
return s;
}
private void go() {
final String srv = normalize(server.getText().toString());
final String u = user.getText().toString().trim();
final String pw = pass.getText().toString();
final boolean key = useKey.isChecked();
if (srv.length() == 0) {
status.setText("Enter your server address.");
return;
}
if (key ? pw.trim().length() == 0 : u.length() == 0) {
status.setText(key ? "Paste your API key." : "Enter your username.");
return;
}
server.setText(srv);
((InputMethodManager) getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(pass.getWindowToken(), 0);
connect.setEnabled(false);
status.setText("Connecting…");
final Prefs p = Prefs.get(this);
p.logout();
App.clear();
HomeActivity.clearCache();
p.setServer(srv, trust.isChecked());
new Job<String>() {
String work() throws Exception {
String version = Api.status(LoginActivity.this, srv);
String lib;
if (key) {
Api.loginWithKey(LoginActivity.this, pw.trim());
lib = "";
} else {
lib = Api.login(LoginActivity.this, srv, u, pw);
}
if (lib.length() > 0) p.setLibrary(lib, "");
return version;
}
void done(String version) {
startActivity(new Intent(LoginActivity.this, HomeActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
finish();
}
void failed(Exception e) {
connect.setEnabled(true);
status.setText(Util.describe(e));
}
}.start();
}
}
+41
View File
@@ -0,0 +1,41 @@
package com.miniabs;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.view.KeyEvent;
/** Headset and Bluetooth buttons: play/pause, next = skip forward, previous = skip back. */
public class MediaButtonReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context c, Intent intent) {
if (!Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) return;
KeyEvent ev = (KeyEvent) intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
if (ev == null || ev.getAction() != KeyEvent.ACTION_DOWN || ev.getRepeatCount() > 0) return;
String action;
switch (ev.getKeyCode()) {
case KeyEvent.KEYCODE_HEADSETHOOK:
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
action = PlayerService.A_TOGGLE;
break;
case KeyEvent.KEYCODE_MEDIA_NEXT:
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
action = PlayerService.A_FWD;
break;
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
case KeyEvent.KEYCODE_MEDIA_REWIND:
action = PlayerService.A_BACK;
break;
case KeyEvent.KEYCODE_MEDIA_STOP:
action = PlayerService.A_PAUSE;
break;
default:
return;
}
// Only start playback from a button press if there is something to play.
if (PlayerService.get() == null && !PlayerService.A_TOGGLE.equals(action)) return;
if (PlayerService.get() == null && Prefs.get(c).lastEpisode() == null) return;
PlayerService.send(c, action);
if (isOrderedBroadcast()) abortBroadcast();
}
}
+301
View File
@@ -0,0 +1,301 @@
package com.miniabs;
import org.json.JSONObject;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.TextView;
/** The now-playing screen. */
public class PlayerActivity extends BaseActivity {
private TextView show, title, date, status, pos, left, desc;
private ImageView cover;
private SeekBar seek;
private ImageButton play;
private Button back, fwd;
private Episode shown;
private Episode pending; // episode just requested, until the service picks it up
private long pendingAt;
private boolean tracking;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setContentView(R.layout.activity_player);
show = (TextView) findViewById(R.id.show);
title = (TextView) findViewById(R.id.title);
date = (TextView) findViewById(R.id.date);
status = (TextView) findViewById(R.id.status);
pos = (TextView) findViewById(R.id.pos);
left = (TextView) findViewById(R.id.left);
desc = (TextView) findViewById(R.id.desc);
cover = (ImageView) findViewById(R.id.cover);
seek = (SeekBar) findViewById(R.id.seek);
play = (ImageButton) findViewById(R.id.play);
back = (Button) findViewById(R.id.back);
fwd = (Button) findViewById(R.id.fwd);
takePending(getIntent());
play.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null) {
ps.toggle();
} else {
Episode e = current();
if (e != null) {
PlayerService.play(PlayerActivity.this, e);
pending = e;
pendingAt = SystemClock.elapsedRealtime();
}
}
refresh();
}
});
back.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
PlayerService ps = PlayerService.get();
if (ps != null) ps.skip(-Prefs.get(PlayerActivity.this).skipBack());
refresh();
}
});
fwd.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
PlayerService ps = PlayerService.get();
if (ps != null) ps.skip(Prefs.get(PlayerActivity.this).skipFwd());
refresh();
}
});
show.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
Episode e = current();
if (e != null) {
startActivity(new Intent(PlayerActivity.this, PodcastActivity.class)
.putExtra("itemId", e.itemId).putExtra("title", e.show).putExtra("author", e.author));
}
}
});
seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
public void onProgressChanged(SeekBar s, int progress, boolean fromUser) {
if (!fromUser) return;
int dur = duration();
if (dur > 0) {
int p = (int) ((long) dur * progress / 1000);
pos.setText(Util.clock(p));
left.setText("-" + Util.clock(dur - p));
}
}
public void onStartTrackingTouch(SeekBar s) {
tracking = true;
}
public void onStopTrackingTouch(SeekBar s) {
tracking = false;
PlayerService ps = PlayerService.get();
int dur = duration();
if (ps != null && ps.episode() != null && dur > 0) {
ps.seekTo((int) ((long) dur * s.getProgress() / 1000));
}
refresh();
}
});
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
takePending(intent);
refresh();
}
private void takePending(Intent i) {
String j = i.getStringExtra("ep");
if (j == null) return;
try {
pending = Episode.fromStored(new JSONObject(j));
pendingAt = SystemClock.elapsedRealtime();
} catch (Exception ignored) {
}
}
@Override
protected void onResume() {
super.onResume();
Prefs p = Prefs.get(this);
back.setText("-" + p.skipBack());
fwd.setText("+" + p.skipFwd());
}
@Override
protected void onTick() {
refresh();
}
/** The episode this screen is about. */
private Episode current() {
PlayerService ps = PlayerService.get();
Episode active = ps != null ? ps.episode() : null;
if (pending != null) {
boolean picked = active != null && active.id.equals(pending.id);
if (!picked && SystemClock.elapsedRealtime() - pendingAt < 5000) return pending;
pending = null;
}
if (active != null) return active;
return Prefs.get(this).lastEpisode();
}
private int duration() {
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null) return ps.durationMs();
Episode e = current();
return e == null ? 0 : (int) (e.duration * 1000);
}
private void refresh() {
Episode e = current();
if (e == null) {
show.setText("");
title.setText("Nothing playing");
date.setText("");
status.setText("Pick an episode from the list.");
desc.setText("");
cover.setVisibility(View.GONE);
setControls(false);
return;
}
if (shown == null || !shown.id.equals(e.id)) {
shown = e;
show.setText(e.show);
title.setText(e.title);
date.setText(Util.join(Util.date(e.published), e.duration > 0 ? Util.duration(e.duration) : ""));
desc.setText(Util.plainText(e.desc));
Covers.load(this, cover, e.itemId, (int) (84 * getResources().getDisplayMetrics().density));
}
PlayerService ps = PlayerService.get();
boolean active = ps != null && ps.episode() != null && ps.episode().id.equals(e.id);
int p, dur;
String st;
boolean playing = false;
if (active) {
p = ps.positionMs();
dur = ps.durationMs();
playing = ps.isPlaying() || (ps.state() == PlayerService.LOADING);
st = ps.status();
long sleep = ps.sleepLeftMs();
if (sleep == -1) st = Util.join(st, "Sleep at end");
else if (sleep > 0) st = Util.join(st, "Sleep in " + Util.clock(sleep));
seek.setSecondaryProgress(ps.bufferedPercent() * 10);
setControls(true);
} else {
double[] pr = App.progress(e.id);
double cur = pr != null ? pr[0] : Math.max(0, App.localPosition(this, e.id));
p = (int) (cur * 1000);
dur = (int) (e.duration * 1000);
st = pending != null ? "Connecting…" : (Downloads.has(this, e.id) ? "Saved on SD card" : "");
playing = pending != null;
seek.setSecondaryProgress(0);
setControls(true);
}
play.setImageResource(playing ? R.drawable.ic_pause : R.drawable.ic_play);
status.setText(st);
if (!tracking) {
pos.setText(Util.clock(p));
left.setText(dur > 0 ? "-" + Util.clock(Math.max(0, dur - p)) : "");
seek.setProgress(dur > 0 ? (int) (1000L * p / dur) : 0);
}
}
private void setControls(boolean on) {
play.setEnabled(on);
back.setEnabled(on);
fwd.setEnabled(on);
seek.setEnabled(on);
}
// ------------------------------------------------------------------ menu
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
menu.clear();
Episode e = current();
if (e == null) return false;
PlayerService ps = PlayerService.get();
boolean active = ps != null && ps.episode() != null && ps.episode().id.equals(e.id);
if (active) menu.add(0, 1, 0, "Sleep timer").setIcon(android.R.drawable.ic_lock_idle_alarm);
menu.add(0, 2, 0, "Mark as played").setIcon(android.R.drawable.ic_menu_agenda);
if (DownloadService.busyWith(e.id)) {
menu.add(0, 3, 0, "Cancel download").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
} else if (Downloads.has(this, e.id)) {
menu.add(0, 3, 0, "Delete download").setIcon(android.R.drawable.ic_menu_delete);
} else {
menu.add(0, 3, 0, "Download").setIcon(android.R.drawable.ic_menu_save);
}
menu.add(0, 4, 0, "Go to show").setIcon(android.R.drawable.ic_menu_view);
if (active) menu.add(0, 5, 0, "Stop").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
final Episode e = current();
if (e == null) return false;
PlayerService ps = PlayerService.get();
boolean active = ps != null && ps.episode() != null && ps.episode().id.equals(e.id);
switch (item.getItemId()) {
case 1:
sleepDialog();
return true;
case 2:
if (active) {
ps.finishAndStop();
toast("Marked as played");
} else {
episodeAction(M_PLAYED, e, null);
}
refresh();
return true;
case 3:
int action = DownloadService.busyWith(e.id) ? M_CANCEL : Downloads.has(this, e.id) ? M_DELETE : M_DOWNLOAD;
episodeAction(action, e, null);
return true;
case 4:
episodeAction(M_SHOW, e, null);
return true;
case 5:
if (active) ps.stop();
refresh();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
private void sleepDialog() {
final int[] minutes = {0, 5, 10, 15, 30, 45, 60, -1};
String[] labels = {"Off", "5 minutes", "10 minutes", "15 minutes", "30 minutes", "45 minutes", "1 hour",
"End of episode"};
new AlertDialog.Builder(this).setTitle("Sleep timer")
.setItems(labels, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int which) {
PlayerService ps = PlayerService.get();
if (ps == null) return;
ps.setSleep(minutes[which]);
toast(minutes[which] == 0 ? "Sleep timer off" : "Sleep timer set");
refresh();
}
}).show();
}
}
+762
View File
@@ -0,0 +1,762 @@
package com.miniabs;
import java.io.File;
import java.io.FileInputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.json.JSONArray;
import org.json.JSONObject;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.net.wifi.WifiManager;
import android.os.Handler;
import android.os.IBinder;
import android.os.PowerManager;
import android.os.SystemClock;
/**
* Plays one episode at a time, keeps the Audiobookshelf playback session in
* sync, and falls back to saved files and local progress when offline.
*/
public class PlayerService extends Service implements MediaPlayer.OnPreparedListener,
MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, MediaPlayer.OnBufferingUpdateListener,
AudioManager.OnAudioFocusChangeListener {
static final String A_PLAY = "com.miniabs.PLAY";
static final String A_TOGGLE = "com.miniabs.TOGGLE";
static final String A_PAUSE = "com.miniabs.PAUSE";
static final String A_BACK = "com.miniabs.BACK";
static final String A_FWD = "com.miniabs.FWD";
static final String A_STOP = "com.miniabs.STOP";
static final int IDLE = 0, LOADING = 1, PLAYING = 2, PAUSED = 3, ENDED = 4, ERROR = 5;
private static final int NOTIF_ID = 1;
private static final long SYNC_EVERY = 15000;
private static final long IDLE_STOP = 30 * 60 * 1000;
private static PlayerService instance;
static PlayerService get() {
return instance;
}
/** Starts playing an episode (or resumes it if it's already loaded). */
static void play(Context c, Episode e) {
c.startService(new Intent(c, PlayerService.class).setAction(A_PLAY).putExtra("ep", e.toJson().toString()));
}
static void send(Context c, String action) {
c.startService(new Intent(c, PlayerService.class).setAction(action));
}
private final Handler h = new Handler();
private final ExecutorService net = Executors.newSingleThreadExecutor();
private AudioManager am;
private WifiManager.WifiLock wifi;
private ComponentName buttons;
private MediaPlayer mp;
private boolean prepared;
private Episode ep;
private volatile String sessionId;
private String contentUrl; // server-relative stream URL, re-tokenised on retry
private boolean local; // playing a downloaded file
private int state = IDLE;
private String status = "";
private int resumeMs; // where to start once prepared
private boolean playOnPrepared;
private int lastPosMs;
private int buffered; // percent
private int generation; // drops results of superseded async work
private int retries;
private long playingSince;
private long lastTick;
private float listened; // seconds listened since the last sync
private long lastSync;
private volatile boolean syncing;
private long pausedAt;
private long sleepAt; // elapsedRealtime when the sleep timer fires, 0 = off
private boolean sleepAtEnd;
private boolean pausedByFocus;
private boolean focusHeld;
private final BroadcastReceiver noisy = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (state == PLAYING) pause(); // headphones unplugged
}
};
private final Runnable ticker = new Runnable() {
public void run() {
tick();
h.postDelayed(this, 1000);
}
};
// --------------------------------------------------------------- lifecycle
@Override
public void onCreate() {
super.onCreate();
instance = this;
am = (AudioManager) getSystemService(AUDIO_SERVICE);
wifi = ((WifiManager) getSystemService(WIFI_SERVICE)).createWifiLock(WifiManager.WIFI_MODE_FULL, "MiniABS");
wifi.setReferenceCounted(false);
buttons = new ComponentName(this, MediaButtonReceiver.class);
registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
h.post(ticker);
}
@Override
public void onDestroy() {
h.removeCallbacks(ticker);
if (ep != null && prepared && state != ENDED) syncNow(true);
releasePlayer();
net.shutdown();
try {
unregisterReceiver(noisy);
} catch (Exception ignored) {
}
am.unregisterMediaButtonEventReceiver(buttons);
dropFocus();
wifi.release();
if (instance == this) instance = null;
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null || intent.getAction() == null) return START_NOT_STICKY;
String a = intent.getAction();
if (A_PLAY.equals(a)) {
try {
play(Episode.fromStored(new JSONObject(intent.getStringExtra("ep"))));
} catch (Exception ignored) {
}
} else if (A_TOGGLE.equals(a)) {
toggle();
} else if (A_PAUSE.equals(a)) {
if (state == PLAYING) pause();
} else if (A_BACK.equals(a)) {
skip(-Prefs.get(this).skipBack());
} else if (A_FWD.equals(a)) {
skip(Prefs.get(this).skipFwd());
} else if (A_STOP.equals(a)) {
stop();
}
if (ep == null) stopSelf();
return START_NOT_STICKY;
}
// --------------------------------------------------------------- public state
Episode episode() { return ep; }
int state() { return state; }
String status() { return status; }
boolean isLocal() { return local; }
int bufferedPercent() { return local ? 100 : buffered; }
boolean isPlaying() {
return state == PLAYING;
}
int positionMs() {
if (mp != null && prepared) {
try {
return mp.getCurrentPosition();
} catch (Exception ignored) {
}
}
return state == ENDED ? durationMs() : resumeMs > 0 ? resumeMs : lastPosMs;
}
int durationMs() {
if (ep != null && ep.duration > 0) return (int) (ep.duration * 1000);
if (mp != null && prepared) {
try {
return mp.getDuration();
} catch (Exception ignored) {
}
}
return 0;
}
/** Milliseconds until the sleep timer fires; 0 = off, -1 = end of episode. */
long sleepLeftMs() {
if (sleepAtEnd) return -1;
if (sleepAt == 0) return 0;
return Math.max(0, sleepAt - SystemClock.elapsedRealtime());
}
void setSleep(int minutes) {
sleepAtEnd = minutes < 0;
sleepAt = minutes > 0 ? SystemClock.elapsedRealtime() + minutes * 60000L : 0;
}
// --------------------------------------------------------------- controls
void play(Episode e) {
if (ep != null && ep.id.equals(e.id) && mp != null && state != ERROR && state != ENDED) {
if (state == PAUSED) resume();
return;
}
if (ep != null && prepared && state != ENDED) syncNow(true);
releasePlayer();
ep = e;
sessionId = null;
contentUrl = null;
retries = 0;
lastPosMs = 0;
resumeMs = 0;
Prefs.get(this).setLastEpisode(e);
open(e, -1, true);
}
void toggle() {
if (ep == null) {
Episode last = Prefs.get(this).lastEpisode();
if (last != null) play(last);
return;
}
if (state == PLAYING) pause();
else if (state == LOADING) playOnPrepared = !playOnPrepared;
else resume();
}
void pause() {
if (mp == null || !prepared) {
playOnPrepared = false;
return;
}
try {
mp.pause();
} catch (Exception ignored) {
}
lastPosMs = positionMs();
state = PAUSED;
status = "Paused";
pausedAt = SystemClock.elapsedRealtime();
syncNow(false);
wifi.release();
showNotification(false);
}
void resume() {
if (ep == null) return;
if (state == ENDED) { // listen again from the start, in a fresh session
App.setProgress(ep.id, 0, ep.duration, false);
open(ep, 0, true);
return;
}
if (mp == null || state == ERROR) {
open(ep, lastPosMs / 1000.0, true);
return;
}
if (prepared) start();
else playOnPrepared = true;
}
void seekTo(int ms) {
int dur = durationMs();
if (dur > 0 && ms > dur - 1000) ms = dur - 1000;
if (ms < 0) ms = 0;
if (mp != null && prepared) {
try {
mp.seekTo(ms);
} catch (Exception ignored) {
}
lastPosMs = ms;
if (state == ENDED) state = PAUSED;
} else {
resumeMs = ms;
lastPosMs = ms;
}
}
void skip(int seconds) {
if (ep == null) return;
seekTo(positionMs() + seconds * 1000);
}
void stop() {
if (ep != null && prepared && state != ENDED) syncNow(true);
releasePlayer();
state = IDLE;
status = "";
am.unregisterMediaButtonEventReceiver(buttons);
dropFocus();
wifi.release();
stopForeground(true);
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).cancel(NOTIF_ID);
ep = null;
stopSelf();
}
/** Marks the current episode as played on the server. */
void markFinished() {
if (ep == null) return;
final Episode e = ep;
final double dur = durationMs() / 1000.0;
App.setProgress(e.id, dur, dur, true);
final String sid = sessionId;
sessionId = null;
net.execute(new Runnable() {
public void run() {
try {
if (sid != null) Api.closeSession(PlayerService.this, sid, dur, 0, dur);
} catch (Exception ignored) {
}
try {
Api.setProgress(PlayerService.this, e.itemId, e.id, dur, dur, Boolean.TRUE);
App.forgetLocal(PlayerService.this, e.id);
} catch (Exception ex) {
App.saveLocal(PlayerService.this, e, dur, dur, true);
}
}
});
}
/** "Mark as played" from the player screen: stop here and tell the server. */
void finishAndStop() {
if (ep == null) return;
int dur = durationMs();
markFinished();
releasePlayer();
state = ENDED;
status = "Marked as played";
lastPosMs = dur;
pausedAt = SystemClock.elapsedRealtime();
wifi.release();
showNotification(false);
}
// --------------------------------------------------------------- opening
private static final class Source {
String url, sessionId, contentUrl, error;
boolean local, offline;
double start;
}
/** Opens a session and the audio. startSec < 0 means "resume where the server says". */
private void open(final Episode e, final double startSec, boolean playNow) {
releasePlayer();
state = LOADING;
status = "Connecting…";
playOnPrepared = playNow;
showNotification(true);
final int gen = ++generation;
final Context ctx = getApplicationContext();
net.execute(new Runnable() {
public void run() {
final Source s = findSource(ctx, e, startSec);
h.post(new Runnable() {
public void run() {
if (gen == generation) startSource(s);
}
});
}
});
}
private Source findSource(Context c, Episode e, double startSec) {
Source s = new Source();
File f = Downloads.audio(c, e);
double localPos = App.localPosition(c, e.id);
boolean localNewer = App.localPending(c, e.id);
try {
if (f != null && !Util.online(c)) throw new java.io.IOException("offline");
Api.ensureFresh(c, 1800);
JSONObject sess = Api.startPlayback(c, e);
s.sessionId = Util.str(sess, "id");
s.start = sess.optDouble("currentTime", 0);
if (localNewer && localPos >= 0) s.start = localPos;
if (f != null) {
s.url = f.getAbsolutePath();
s.local = true;
} else {
JSONArray tracks = sess.optJSONArray("audioTracks");
JSONObject t = tracks == null ? null : tracks.optJSONObject(0);
String cu = t == null ? "" : Util.str(t, "contentUrl");
if (cu.length() == 0) {
s.error = "The server sent no audio for this episode.";
} else if (cu.contains(".m3u8")) {
s.error = "This episode needs transcoding, which Android 2.x can't play. Try downloading it.";
} else {
s.contentUrl = cu;
s.url = Api.withToken(c, Api.absolute(c, cu));
}
}
} catch (Exception ex) {
if (f != null) {
s.url = f.getAbsolutePath();
s.local = true;
s.offline = true;
s.start = Math.max(0, localPos);
} else {
s.error = Util.describe(ex);
}
}
if (startSec >= 0) s.start = startSec;
double[] p = App.progress(e.id);
if (startSec < 0 && p != null && p[2] > 0) s.start = 0; // finished before: start over
if (e.duration > 0 && s.start > e.duration - 5) s.start = 0;
return s;
}
private void startSource(Source s) {
if (s.error != null) {
fail(s.error);
return;
}
sessionId = s.sessionId;
contentUrl = s.contentUrl;
local = s.local;
resumeMs = (int) (s.start * 1000);
lastPosMs = resumeMs;
prepare(s.url, s.offline ? "Offline · progress will sync later" : null);
}
private void prepare(String url, String note) {
releasePlayer();
mp = new MediaPlayer();
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
mp.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
mp.setOnPreparedListener(this);
mp.setOnCompletionListener(this);
mp.setOnErrorListener(this);
mp.setOnBufferingUpdateListener(this);
try {
if (local) {
FileInputStream in = new FileInputStream(url);
try {
mp.setDataSource(in.getFD());
} finally {
in.close();
}
} else {
mp.setDataSource(url);
wifi.acquire();
}
state = LOADING;
status = note != null ? note : local ? "Opening saved file…" : "Buffering…";
mp.prepareAsync();
} catch (Exception ex) {
fail("Can't open the audio: " + Util.describe(ex));
}
}
@Override
public void onPrepared(MediaPlayer p) {
if (p != mp) return;
prepared = true;
int dur = durationMs();
if (resumeMs > 1000 && (dur <= 0 || resumeMs < dur - 2000)) {
try {
mp.seekTo(resumeMs);
} catch (Exception ignored) {
}
}
resumeMs = 0;
if (playOnPrepared) {
start();
} else {
state = PAUSED;
status = "Paused";
showNotification(false);
}
}
private void start() {
if (mp == null) return;
if (!focusHeld) {
int r = am.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
focusHeld = r == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
}
am.registerMediaButtonEventReceiver(buttons);
try {
mp.setVolume(1f, 1f);
mp.start();
} catch (Exception ex) {
fail("Playback failed: " + Util.describe(ex));
return;
}
if (!local) wifi.acquire();
state = PLAYING;
status = local ? (sessionId == null ? "Saved file · offline" : "Saved file") : "Streaming";
playingSince = SystemClock.elapsedRealtime();
lastTick = playingSince;
if (lastSync == 0) lastSync = playingSince;
showNotification(true);
}
private void fail(String msg) {
releasePlayer();
state = ERROR;
status = msg;
pausedAt = SystemClock.elapsedRealtime();
wifi.release();
showNotification(false);
}
private void releasePlayer() {
if (mp != null) {
if (prepared) lastPosMs = positionMs();
try {
mp.reset();
mp.release();
} catch (Exception ignored) {
}
}
mp = null;
prepared = false;
buffered = 0;
}
// --------------------------------------------------------------- player callbacks
@Override
public void onBufferingUpdate(MediaPlayer p, int percent) {
if (p == mp) buffered = percent;
}
@Override
public void onCompletion(MediaPlayer p) {
if (p != mp) return;
int pos = positionMs(), dur = durationMs();
// Old Android's streamer "completes" when the connection drops. Treat that as an error.
if (!local && dur > 0 && pos < dur - 30000) {
lastPosMs = pos;
retryStream("Connection lost, reconnecting…");
return;
}
state = ENDED;
status = "Finished";
lastPosMs = dur;
pausedAt = SystemClock.elapsedRealtime();
wifi.release();
if (sleepAtEnd) sleepAtEnd = false;
markFinished();
showNotification(false);
}
@Override
public boolean onError(MediaPlayer p, int what, int extra) {
if (p != mp) return true;
if (prepared) lastPosMs = positionMs();
if (!local && contentUrl != null) {
retryStream("Connection lost, reconnecting…");
} else {
fail("Playback error (" + what + "/" + extra + ").");
}
return true;
}
/** Re-opens the stream at the last position with a fresh token. Gives up after three tries. */
private void retryStream(final String msg) {
if (retries >= 3 || contentUrl == null) {
fail("Lost connection to the server. Press play to try again.");
retries = 0;
return;
}
retries++;
final boolean wasPlaying = state == PLAYING || playOnPrepared;
final int pos = lastPosMs;
final String cu = contentUrl;
releasePlayer();
state = LOADING;
status = msg;
final int gen = ++generation;
final Context ctx = getApplicationContext();
h.postDelayed(new Runnable() {
public void run() {
if (gen != generation) return;
net.execute(new Runnable() {
public void run() {
try {
if (Prefs.get(ctx).canRefresh()) Api.refresh(ctx, true);
} catch (Exception ignored) {
}
h.post(new Runnable() {
public void run() {
if (gen != generation) return;
resumeMs = pos;
playOnPrepared = wasPlaying;
prepare(Api.withToken(ctx, Api.absolute(ctx, cu)), msg);
}
});
}
});
}
}, 1500L * retries);
}
@Override
public void onAudioFocusChange(int change) {
switch (change) {
case AudioManager.AUDIOFOCUS_LOSS:
focusHeld = false;
pausedByFocus = false;
if (state == PLAYING) pause();
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
if (state == PLAYING) {
pause();
pausedByFocus = true;
}
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
if (mp != null && state == PLAYING) mp.setVolume(0.2f, 0.2f);
break;
case AudioManager.AUDIOFOCUS_GAIN:
focusHeld = true;
if (mp != null) mp.setVolume(1f, 1f);
if (pausedByFocus) {
pausedByFocus = false;
if (state == PAUSED) start();
}
break;
default:
break;
}
}
private void dropFocus() {
am.abandonAudioFocus(this);
focusHeld = false;
}
// --------------------------------------------------------------- sync
private void tick() {
if (ep == null) return;
long now = SystemClock.elapsedRealtime();
if (state == PLAYING) {
float d = (now - lastTick) / 1000f;
if (d > 0 && d < 5) listened += d;
lastTick = now;
lastPosMs = positionMs();
double dur = durationMs() / 1000.0;
App.setProgress(ep.id, lastPosMs / 1000.0, dur, false);
if (now - lastSync > SYNC_EVERY) syncNow(false);
if (now - playingSince > 60000) retries = 0;
if (sleepAt > 0 && now >= sleepAt) {
sleepAt = 0;
pause();
status = "Sleep timer: paused";
}
} else if (state == PAUSED || state == ENDED || state == ERROR) {
// Free the phone's memory if left paused for a long time.
if (pausedAt > 0 && now - pausedAt > IDLE_STOP) {
pausedAt = 0;
stop();
}
}
}
/** Sends the position to the server (or keeps it on the phone when offline). */
private void syncNow(final boolean close) {
if (ep == null) return;
if (syncing && !close) return;
final Episode e = ep;
final double cur = (prepared ? positionMs() : lastPosMs) / 1000.0;
final double dur = durationMs() / 1000.0;
final float tl = listened;
final String sid = sessionId;
final Context c = getApplicationContext();
listened = 0;
lastSync = SystemClock.elapsedRealtime();
if (close) sessionId = null;
if (cur <= 0 && tl <= 0) return;
syncing = true;
final boolean saved = Downloads.has(c, e.id);
Runnable job = new Runnable() {
public void run() {
boolean ok = false;
try {
if (sid != null) {
try {
if (close) Api.closeSession(c, sid, cur, tl, dur);
else Api.syncSession(c, sid, cur, tl, dur);
ok = true;
} catch (Api.ApiException ex) {
if (ex.code == 404) {
// Session expired on the server (e.g. it restarted): save progress directly.
h.post(new Runnable() {
public void run() {
if (sid.equals(sessionId)) sessionId = null;
}
});
} else {
throw ex;
}
}
}
if (!ok && Util.online(c)) {
Api.setProgress(c, e.itemId, e.id, cur, dur, null);
ok = true;
}
} catch (Exception ignored) {
ok = false;
}
if (!ok) App.saveLocal(c, e, cur, dur, true);
else if (saved) App.saveLocal(c, e, cur, dur, false);
else App.forgetLocal(c, e.id);
syncing = false;
}
};
try {
net.execute(job);
} catch (Exception ex) {
// executor already shut down (service ending): keep the position on the phone
App.saveLocal(c, e, cur, dur, true);
syncing = false;
}
}
// --------------------------------------------------------------- notification
private void showNotification(boolean foreground) {
if (ep == null) return;
Notification n = new Notification(R.drawable.ic_stat, null, 0);
n.flags |= foreground ? Notification.FLAG_ONGOING_EVENT : 0;
Intent i = new Intent(this, PlayerActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pi = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
String what;
switch (state) {
case PLAYING: what = "Playing"; break;
case LOADING: what = "Loading…"; break;
case ENDED: what = "Finished"; break;
case ERROR: what = "Stopped: tap to retry"; break;
default: what = "Paused"; break;
}
n.setLatestEventInfo(this, ep.title, Util.join(what, ep.show), pi);
if (foreground) {
startForeground(NOTIF_ID, n);
} else {
stopForeground(false);
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).notify(NOTIF_ID, n);
}
}
}
+203
View File
@@ -0,0 +1,203 @@
package com.miniabs;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import android.content.Intent;
import android.os.Bundle;
import android.view.ContextMenu;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView;
/** All episodes of one podcast. */
public class PodcastActivity extends BaseActivity {
private String itemId;
private ListView list;
private TextView empty, title, subtitle;
private ProgressBar loading;
private Rows adapter;
private List<Episode> episodes;
private boolean oldestFirst, hidePlayed;
private int ticks;
private Episode menuEpisode;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setContentView(R.layout.activity_podcast);
itemId = getIntent().getStringExtra("itemId");
list = (ListView) findViewById(R.id.list);
empty = (TextView) findViewById(R.id.empty);
loading = (ProgressBar) findViewById(R.id.loading);
title = (TextView) findViewById(R.id.title);
subtitle = (TextView) findViewById(R.id.subtitle);
title.setText(getIntent().getStringExtra("title"));
subtitle.setText(getIntent().getStringExtra("author"));
Covers.load(this, (ImageView) findViewById(R.id.cover), itemId,
(int) (44 * getResources().getDisplayMetrics().density));
Prefs p = Prefs.get(this);
oldestFirst = p.sp.getBoolean("oldest_first", false);
hidePlayed = p.sp.getBoolean("hide_played", false);
adapter = new Rows(this);
adapter.showPodcastName = false;
list.setAdapter(adapter);
registerForContextMenu(list);
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
public void onItemClick(AdapterView<?> parent, View v, int pos, long id) {
Rows.Row r = adapter.getItem(pos);
if (r.type == Rows.EPISODE) playEpisode(r.ep);
}
});
setupMiniBar();
load();
}
@Override
protected void onResume() {
super.onResume();
if (episodes != null) adapter.notifyDataSetChanged();
}
@Override
protected void onTick() {
super.onTick();
if (++ticks % 3 == 0) adapter.notifyDataSetChanged();
}
private void load() {
loading.setVisibility(View.VISIBLE);
empty.setVisibility(View.GONE);
new Job<List<Episode>>() {
String author = "";
String showTitle = "";
List<Episode> work() throws Exception {
if (!App.progressFresh()) App.loadProgress(PodcastActivity.this);
JSONObject it = Api.item(PodcastActivity.this, itemId);
JSONObject media = it.optJSONObject("media");
JSONObject md = media == null ? null : media.optJSONObject("metadata");
showTitle = Util.str(md, "title");
author = Util.str(md, "author");
JSONArray eps = media == null ? null : media.optJSONArray("episodes");
List<Episode> out = new ArrayList<Episode>();
for (int i = 0; eps != null && i < eps.length(); i++) {
Episode e = Episode.fromApi(eps.optJSONObject(i), itemId, showTitle, author);
if (e != null) out.add(e);
}
return out;
}
void done(List<Episode> list) {
loading.setVisibility(View.GONE);
episodes = list;
if (showTitle.length() > 0) title.setText(showTitle);
String n = list.size() == 1 ? "1 episode" : list.size() + " episodes";
subtitle.setText(Util.join(author, n));
render();
}
void failed(Exception e) {
loading.setVisibility(View.GONE);
if (e instanceof Api.AuthException) {
showError(e);
return;
}
empty.setText(Util.describe(e));
empty.setVisibility(View.VISIBLE);
}
}.start();
}
private void render() {
if (episodes == null) return;
List<Episode> sorted = new ArrayList<Episode>(episodes);
Collections.sort(sorted, new Comparator<Episode>() {
public int compare(Episode a, Episode b) {
int c = a.published == b.published ? 0 : (a.published < b.published ? -1 : 1);
return oldestFirst ? c : -c;
}
});
List<Rows.Row> rows = new ArrayList<Rows.Row>();
int hidden = 0;
for (Episode e : sorted) {
double[] p = App.progress(e.id);
if (hidePlayed && p != null && p[2] > 0) {
hidden++;
continue;
}
rows.add(Rows.Row.episode(e));
}
if (hidden > 0) rows.add(0, Rows.Row.header(hidden + " PLAYED EPISODES HIDDEN"));
adapter.set(rows);
boolean none = rows.isEmpty();
empty.setText(none ? "No episodes." : "");
empty.setVisibility(none ? View.VISIBLE : View.GONE);
}
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo info) {
Rows.Row r = adapter.getItem(((AdapterView.AdapterContextMenuInfo) info).position);
if (r.type != Rows.EPISODE) return;
menuEpisode = r.ep;
episodeMenu(menu, r.ep, false);
}
@Override
public boolean onContextItemSelected(MenuItem item) {
return onEpisodeContextItem(item, menuEpisode, new Runnable() {
public void run() {
render();
}
}) || super.onContextItemSelected(item);
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
menu.clear();
menu.add(0, 1, 0, oldestFirst ? "Newest first" : "Oldest first").setIcon(android.R.drawable.ic_menu_sort_by_size);
menu.add(0, 2, 0, hidePlayed ? "Show played" : "Hide played").setIcon(android.R.drawable.ic_menu_view);
menu.add(0, 3, 0, "Refresh").setIcon(android.R.drawable.ic_menu_rotate);
menu.add(0, 4, 0, "Now playing").setIcon(android.R.drawable.ic_media_play);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
Prefs p = Prefs.get(this);
switch (item.getItemId()) {
case 1:
oldestFirst = !oldestFirst;
p.sp.edit().putBoolean("oldest_first", oldestFirst).commit();
render();
list.setSelection(0);
return true;
case 2:
hidePlayed = !hidePlayed;
p.sp.edit().putBoolean("hide_played", hidePlayed).commit();
render();
return true;
case 3:
App.invalidate();
load();
return true;
case 4:
startActivity(new Intent(this, PlayerActivity.class));
return true;
default:
return super.onOptionsItemSelected(item);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
package com.miniabs;
import java.util.UUID;
import org.json.JSONObject;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import android.util.Base64;
/** Settings and sign-in state. */
final class Prefs {
static final String MODE_JWT = "jwt"; // ABS 2.26+: short access token + refresh token
static final String MODE_LEGACY = "legacy"; // older servers: one long-lived token
static final String MODE_KEY = "key"; // API key pasted by the user
private static Prefs instance;
final SharedPreferences sp;
private Prefs(Context c) {
sp = PreferenceManager.getDefaultSharedPreferences(c);
}
static synchronized Prefs get(Context c) {
if (instance == null) instance = new Prefs(c.getApplicationContext());
return instance;
}
private String s(String k) {
return sp.getString(k, "");
}
String server() { return s("server"); }
String username() { return s("username"); }
String token() { return s("access"); }
String refreshToken() { return s("refresh"); }
String mode() { return s("mode"); }
boolean trustAll() { return sp.getBoolean("trust_all", false); }
boolean wifiOnly() { return sp.getBoolean("wifi_only", true); }
boolean covers() { return sp.getBoolean("covers", true); }
String libraryId() { return s("library_id"); }
String libraryName() { return s("library_name"); }
int skipBack() { return num("skip_back", 15); }
int skipFwd() { return num("skip_fwd", 30); }
private int num(String k, int def) {
try {
return Integer.parseInt(sp.getString(k, String.valueOf(def)));
} catch (Exception e) {
return def;
}
}
boolean loggedIn() {
return server().length() > 0 && token().length() > 0;
}
boolean canRefresh() {
return MODE_JWT.equals(mode()) && refreshToken().length() > 0;
}
void setServer(String server, boolean trustAll) {
sp.edit().putString("server", server).putBoolean("trust_all", trustAll).commit();
}
synchronized void setAuth(String user, String mode, String access, String refresh) {
SharedPreferences.Editor e = sp.edit()
.putString("username", user)
.putString("mode", mode)
.putString("refresh", refresh == null ? "" : refresh);
putToken(e, access);
e.commit();
}
synchronized void setTokens(String access, String refresh) {
SharedPreferences.Editor e = sp.edit();
if (refresh != null && refresh.length() > 0) e.putString("refresh", refresh);
putToken(e, access);
e.commit();
}
/** Stores the token with its lifetime measured on this phone's clock (old phones' clocks drift). */
private void putToken(SharedPreferences.Editor e, String access) {
long lifetime = 0;
try {
String[] parts = access.split("\\.");
if (parts.length == 3) {
JSONObject p = new JSONObject(new String(Base64.decode(parts[1], Base64.URL_SAFE), "UTF-8"));
long exp = p.optLong("exp", 0), iat = p.optLong("iat", 0);
if (exp > 0 && iat > 0 && exp > iat) lifetime = exp - iat;
}
} catch (Exception ignored) {
// not a JWT, or unreadable: treat as non-expiring
}
e.putString("access", access)
.putLong("token_lifetime", lifetime)
.putLong("token_obtained", System.currentTimeMillis());
}
/** Seconds left on the access token; Long.MAX_VALUE when it doesn't expire. */
long tokenSecondsLeft() {
long life = sp.getLong("token_lifetime", 0);
if (life <= 0) return Long.MAX_VALUE;
long age = (System.currentTimeMillis() - sp.getLong("token_obtained", 0)) / 1000;
if (age < 0) return 0; // clock went backwards; be safe and refresh
return life - age;
}
long tokenObtainedAt() {
return sp.getLong("token_obtained", 0);
}
void logout() {
sp.edit().remove("access").remove("refresh").remove("mode")
.remove("library_id").remove("library_name").remove("last_episode").commit();
}
void setLibrary(String id, String name) {
sp.edit().putString("library_id", id).putString("library_name", name).commit();
}
String deviceId() {
String id = s("device_id");
if (id.length() == 0) {
id = UUID.randomUUID().toString();
sp.edit().putString("device_id", id).commit();
}
return id;
}
void setLastEpisode(Episode e) {
sp.edit().putString("last_episode", e == null ? "" : e.toJson().toString()).commit();
}
Episode lastEpisode() {
String j = s("last_episode");
if (j.length() == 0) return null;
try {
return Episode.fromStored(new JSONObject(j));
} catch (Exception e) {
return null;
}
}
}
+207
View File
@@ -0,0 +1,207 @@
package com.miniabs;
import java.util.ArrayList;
import java.util.List;
import android.app.Activity;
import android.graphics.drawable.GradientDrawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
/** One list adapter for every screen: section headers, episodes, shows and "load more". */
final class Rows extends BaseAdapter {
static final int HEADER = 0, EPISODE = 1, SHOW = 2, MORE = 3;
static final class Row {
final int type;
String text;
Episode ep;
Show show;
Row(int type) {
this.type = type;
}
static Row header(String t) {
Row r = new Row(HEADER);
r.text = t;
return r;
}
static Row episode(Episode e) {
Row r = new Row(EPISODE);
r.ep = e;
return r;
}
static Row show(Show s) {
Row r = new Row(SHOW);
r.show = s;
return r;
}
static Row more(String t) {
Row r = new Row(MORE);
r.text = t;
return r;
}
}
static final class Show {
String id, title, author;
int episodes;
}
private final Activity act;
private final LayoutInflater inf;
final List<Row> rows = new ArrayList<Row>();
boolean showPodcastName = true;
private final int coverPx;
private final android.util.SparseArray<GradientDrawable> dots = new android.util.SparseArray<GradientDrawable>();
private GradientDrawable dot(int color) {
GradientDrawable cached = dots.get(color);
if (cached != null) return cached;
GradientDrawable d = new GradientDrawable();
dots.put(color, d);
d.setShape(GradientDrawable.OVAL);
d.setColor(color);
return d;
}
Rows(Activity a) {
act = a;
inf = LayoutInflater.from(a);
coverPx = (int) (40 * a.getResources().getDisplayMetrics().density);
}
void set(List<Row> list) {
rows.clear();
rows.addAll(list);
notifyDataSetChanged();
}
@Override
public int getCount() {
return rows.size();
}
@Override
public Row getItem(int i) {
return rows.get(i);
}
@Override
public long getItemId(int i) {
return i;
}
@Override
public int getViewTypeCount() {
return 4;
}
@Override
public int getItemViewType(int i) {
return rows.get(i).type;
}
@Override
public boolean areAllItemsEnabled() {
return false;
}
@Override
public boolean isEnabled(int i) {
return rows.get(i).type != HEADER;
}
@Override
public View getView(int i, View v, ViewGroup parent) {
Row r = rows.get(i);
switch (r.type) {
case HEADER:
if (v == null) v = inf.inflate(R.layout.row_header, parent, false);
((TextView) v).setText(r.text);
return v;
case MORE:
if (v == null) v = inf.inflate(R.layout.row_more, parent, false);
((TextView) v).setText(r.text);
return v;
case SHOW:
if (v == null) v = inf.inflate(R.layout.row_show, parent, false);
bindShow(v, r.show);
return v;
default:
if (v == null) v = inf.inflate(R.layout.row_episode, parent, false);
bindEpisode(v, r.ep);
return v;
}
}
private void bindShow(View v, Show s) {
((TextView) v.findViewById(R.id.title)).setText(s.title);
String n = s.episodes == 1 ? "1 episode" : s.episodes + " episodes";
((TextView) v.findViewById(R.id.meta)).setText(Util.join(s.author, n));
Covers.load(act, (ImageView) v.findViewById(R.id.cover), s.id, coverPx);
}
private void bindEpisode(View v, Episode e) {
TextView show = (TextView) v.findViewById(R.id.show);
TextView title = (TextView) v.findViewById(R.id.title);
TextView meta = (TextView) v.findViewById(R.id.meta);
ProgressBar bar = (ProgressBar) v.findViewById(R.id.progress);
View dotView = v.findViewById(R.id.dot);
if (showPodcastName && e.show.length() > 0) {
show.setVisibility(View.VISIBLE);
show.setText(e.show);
} else {
show.setVisibility(View.GONE);
}
title.setText(e.title);
double[] p = App.progress(e.id);
boolean finished = p != null && p[2] > 0;
double cur = p == null ? 0 : p[0];
double dur = e.duration > 0 ? e.duration : (p == null ? 0 : p[1]);
boolean started = !finished && cur > 5;
String m = Util.date(e.published);
if (finished) {
m = Util.join(m, "Played");
} else if (started && dur > 0) {
m = Util.join(m, Util.duration(Math.max(0, dur - cur)) + " left");
} else if (dur > 0) {
m = Util.join(m, Util.duration(dur));
}
if (DownloadService.busyWith(e.id)) {
Episode now = DownloadService.current;
m = Util.join(m, now != null && now.id.equals(e.id) ? "Saving " + DownloadService.percent + "%" : "Queued");
} else if (Downloads.has(act, e.id)) {
m = Util.join(m, "Saved");
}
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null && ps.episode().id.equals(e.id) && ps.isPlaying()) {
m = Util.join("Now playing", m);
}
meta.setText(m);
int res = act.getResources().getColor(finished ? R.color.dim : R.color.text);
title.setTextColor(res);
if (started && dur > 0) {
bar.setVisibility(View.VISIBLE);
bar.setProgress((int) (1000 * Math.min(1.0, cur / dur)));
} else {
bar.setVisibility(View.GONE);
}
int color = act.getResources().getColor(finished ? R.color.line : started ? R.color.accent : R.color.fresh);
dotView.setBackgroundDrawable(dot(color));
}
}
+77
View File
@@ -0,0 +1,77 @@
package com.miniabs;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.preference.ListPreference;
import android.preference.Preference;
import android.preference.PreferenceActivity;
import android.widget.Toast;
public class SettingsActivity extends PreferenceActivity {
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
addPreferencesFromResource(R.xml.settings);
final Prefs p = Prefs.get(this);
Preference.OnPreferenceChangeListener skipSummary = new Preference.OnPreferenceChangeListener() {
public boolean onPreferenceChange(Preference pref, Object value) {
pref.setSummary(value + " seconds");
return true;
}
};
ListPreference back = (ListPreference) findPreference("skip_back");
ListPreference fwd = (ListPreference) findPreference("skip_fwd");
back.setSummary(p.skipBack() + " seconds");
fwd.setSummary(p.skipFwd() + " seconds");
back.setOnPreferenceChangeListener(skipSummary);
fwd.setOnPreferenceChangeListener(skipSummary);
findPreference("covers").setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
public boolean onPreferenceChange(Preference pref, Object value) {
Covers.clearMemory();
return true;
}
});
String who = p.username().length() > 0 ? p.username() : "API key";
findPreference("account").setSummary(who + " @ " + p.server());
findPreference("delete_all").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this)
.setMessage("Delete every downloaded episode from the SD card?")
.setPositiveButton("Delete", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
PlayerService ps = PlayerService.get();
if (ps != null && ps.isLocal()) ps.stop();
int n = Downloads.deleteAll(SettingsActivity.this);
Toast.makeText(SettingsActivity.this, n + " deleted", Toast.LENGTH_SHORT).show();
}
})
.setNegativeButton("Cancel", null).show();
return true;
}
});
findPreference("logout").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this)
.setMessage("Log out of " + p.server() + "? Downloads stay on the SD card.")
.setPositiveButton("Log out", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
PlayerService ps = PlayerService.get();
if (ps != null) ps.stop();
p.logout();
App.clear();
HomeActivity.clearCache();
finish(); // Home sees the logout on resume and opens the login screen
}
})
.setNegativeButton("Cancel", null).show();
return true;
}
});
}
}
+107
View File
@@ -0,0 +1,107 @@
package com.miniabs;
import java.io.FileNotFoundException;
import java.net.ConnectException;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import javax.net.ssl.SSLException;
import org.json.JSONObject;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.text.Html;
final class Util {
private Util() {
}
/** optString that treats JSON null as empty (old org.json returns "null"). */
static String str(JSONObject o, String key) {
if (o == null || o.isNull(key)) return "";
return o.optString(key, "");
}
/** "54 min", "1 h 12 min". */
static String duration(double sec) {
int m = (int) Math.round(sec / 60.0);
if (sec > 0 && m == 0) m = 1;
if (m < 60) return m + " min";
return (m / 60) + " h " + (m % 60) + " min";
}
/** "4:05", "1:02:03". */
static String clock(long ms) {
if (ms < 0) ms = 0;
long s = ms / 1000;
long h = s / 3600, m = (s / 60) % 60, sec = s % 60;
String ss = sec < 10 ? "0" + sec : String.valueOf(sec);
if (h > 0) return h + ":" + (m < 10 ? "0" + m : String.valueOf(m)) + ":" + ss;
return m + ":" + ss;
}
static String date(long ms) {
if (ms <= 0) return "";
Calendar now = Calendar.getInstance(), c = Calendar.getInstance();
c.setTimeInMillis(ms);
String pattern = c.get(Calendar.YEAR) == now.get(Calendar.YEAR) ? "d MMM" : "d MMM yyyy";
return new SimpleDateFormat(pattern, Locale.getDefault()).format(new Date(ms));
}
static String plainText(String html) {
if (html == null || html.length() == 0) return "";
try {
return Html.fromHtml(html).toString().trim();
} catch (Exception e) {
return html;
}
}
static String join(String a, String b) {
if (a == null || a.length() == 0) return b == null ? "" : b;
if (b == null || b.length() == 0) return a;
return a + " · " + b;
}
static boolean onWifi(Context c) {
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
return ni != null && ni.isConnected() && ni.getType() == ConnectivityManager.TYPE_WIFI;
}
static boolean online(Context c) {
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
return ni != null && ni.isConnected();
}
/** Turns an exception into a short message that fits a 240px screen. */
static String describe(Throwable e) {
if (e instanceof Api.AuthException) return e.getMessage();
if (e instanceof Api.ApiException) {
Api.ApiException a = (Api.ApiException) e;
if (a.code == 401) return "Not signed in. Please log in again.";
if (a.code == 403) return "Your account isn't allowed to do that.";
if (a.code == 404) return "Not found on the server.";
String m = a.getMessage();
return "Server error " + a.code + (m != null && m.length() > 0 ? ": " + m : "");
}
if (e instanceof UnknownHostException) return "Server not found. Check the address and your connection.";
if (e instanceof SocketTimeoutException) return "The server took too long to answer.";
if (e instanceof ConnectException) return "Can't connect to the server. Is it running and reachable?";
if (e instanceof SSLException) {
return "Secure (HTTPS) connection failed. Android 2.x can't use modern HTTPS: try your server's local http:// address.";
}
if (e instanceof SocketException) return "Connection dropped. Check your network.";
if (e instanceof FileNotFoundException) return "Not found on the server.";
String m = e.getMessage();
return m == null || m.length() == 0 ? e.getClass().getSimpleName() : m;
}
}