Renames the app (label, texts, Audiobookshelf client name, User-Agent, APK). The package name, preferences and signing key stay, so it installs over MiniABS and keeps its settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
793 lines
27 KiB
Java
793 lines
27 KiB
Java
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 = com.miniabs.net.Threads.pool("player-net", 1);
|
|
|
|
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 int stallPos = -1; // position last seen moving
|
|
private long stallSince;
|
|
private boolean stallWarned;
|
|
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, "Podlite");
|
|
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();
|
|
StreamProxy.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 = "En pausa";
|
|
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 = "Marcado como escoitado";
|
|
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 = "Conectando…";
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Direct URL for http:// servers; a local proxy URL for https:// ones (see StreamProxy). */
|
|
private static String streamUrl(Context c, String contentUrl) throws java.io.IOException {
|
|
if (StreamProxy.needed(c)) return StreamProxy.get(c).register(contentUrl);
|
|
return Api.withToken(c, Api.absolute(c, contentUrl));
|
|
}
|
|
|
|
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 = "O servidor non enviou audio para este episodio.";
|
|
} else if (cu.contains(".m3u8")) {
|
|
s.error = "Este episodio precisa transcodificación, que Android 2.x non pode reproducir. Proba a descargalo.";
|
|
} else {
|
|
s.contentUrl = cu;
|
|
s.url = streamUrl(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 ? "Sen conexión · o progreso sincronizarase despois" : 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 ? "Abrindo o ficheiro gardado…" : "Cargando…";
|
|
mp.prepareAsync();
|
|
} catch (Exception ex) {
|
|
fail("Non se pode abrir o 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 = "En pausa";
|
|
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("Fallou a reprodución: " + Util.describe(ex));
|
|
return;
|
|
}
|
|
if (!local) wifi.acquire();
|
|
state = PLAYING;
|
|
status = local ? (sessionId == null ? "Ficheiro gardado · sen conexión" : "Ficheiro gardado") : "Reproducindo en liña";
|
|
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();
|
|
// Only playback that got to (nearly) the end counts as listened; anything else is never
|
|
// reported as played. Old Android's streamer "completes" when the connection drops, and a
|
|
// saved file cut short (SD card, interrupted copy) "completes" early too.
|
|
if (dur > 0 && pos < dur - 60000) {
|
|
lastPosMs = pos;
|
|
if (!local && contentUrl != null) {
|
|
retryStream("Perdeuse a conexión, reconectando…");
|
|
} else {
|
|
fail("O ficheiro rematou antes de tempo (" + Util.clock(pos) + "). Proba a descargalo de novo.");
|
|
}
|
|
return;
|
|
}
|
|
state = ENDED;
|
|
status = "Rematado";
|
|
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("Perdeuse a conexión, reconectando…");
|
|
} else {
|
|
fail("Erro de reprodución (" + 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("Perdeuse a conexión co servidor. Preme reproducir para tentalo de novo.");
|
|
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;
|
|
try {
|
|
prepare(streamUrl(ctx, cu), msg);
|
|
} catch (Exception ex) {
|
|
fail(Util.describe(ex));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}, 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;
|
|
// "playing" but not moving for 12 s: say so instead of staying silent
|
|
if (lastPosMs != stallPos) {
|
|
stallPos = lastPosMs;
|
|
stallSince = now;
|
|
stallWarned = false;
|
|
} else if (!stallWarned && now - stallSince > 12000) {
|
|
stallWarned = true;
|
|
status = "A reprodución quedou parada. Proba a pausar e reproducir de novo.";
|
|
android.widget.Toast.makeText(this, status, android.widget.Toast.LENGTH_LONG).show();
|
|
}
|
|
if (sleepAt > 0 && now >= sleepAt) {
|
|
sleepAt = 0;
|
|
pause();
|
|
status = "Temporizador: en pausa";
|
|
}
|
|
} 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 = "Reproducindo"; break;
|
|
case LOADING: what = "Cargando…"; break;
|
|
case ENDED: what = "Rematado"; break;
|
|
case ERROR: what = "Detido: toca para tentalo de novo"; break;
|
|
default: what = "En pausa"; 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);
|
|
}
|
|
}
|
|
}
|