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