Import miniabs 1.0 source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
4fe9eed870
78 files changed
+5800
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user