Files
podlite/src/com/miniabs/PlayerService.java
T
XoséandClaude Opus 5.5 a4be0bab44 Podlite 1.8: MiniABS is now Podlite
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>
2026-10-19 16:57:47 +02:00

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);
}
}
}