Private
Public Access
- Downloads for offline listening: long-press a track, album, artist or playlist -> Download; Menu -> Downloads lists them (progress, space used/free, play/shuffle, retry, remove). Saved on the SD card (Android/data/com.minamp/ files/downloads). MP3 originals kept as they are unless above a lower download quality; the rest transcoded to MP3 (320 kbps by default). One at a time, Range resume for originals, retried when the server is back, Wi-Fi lock. - Downloaded tracks play from the SD card, offline too; plays made offline are reported to Plex when a connection works again. - Server.pick: when last time's connection was remote, give the plain home addresses 2 s first (a hairpinned remote address kept us encrypted and capped at home). test/PreferHomeTest.java. - Playback "playing" but not moving for 12 s: say so instead of staying silent. - Menu -> Close Minamp: stop the music and close every screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
781 lines
31 KiB
Java
781 lines
31 KiB
Java
package com.minamp;
|
|
|
|
import java.io.File;
|
|
import java.io.FileInputStream;
|
|
import java.io.FileOutputStream;
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.util.ArrayList;
|
|
import java.util.concurrent.ExecutorService;
|
|
|
|
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.Environment;
|
|
import android.os.IBinder;
|
|
import android.os.PowerManager;
|
|
import android.telephony.PhoneStateListener;
|
|
import android.telephony.TelephonyManager;
|
|
import android.widget.Toast;
|
|
|
|
import com.minamp.net.Http;
|
|
import com.minamp.net.StreamProxy;
|
|
import com.minamp.plex.Item;
|
|
import com.minamp.plex.Library;
|
|
import com.minamp.plex.Plex;
|
|
|
|
/**
|
|
* Foreground playback service: one MediaPlayer per track, fed through the loopback
|
|
* {@link StreamProxy}. Handles audio focus, calls, headset buttons/unplug, Plex timeline
|
|
* reporting, retries after network changes, and the sleep timer.
|
|
*/
|
|
public class PlayerService extends Service implements MediaPlayer.OnPreparedListener,
|
|
MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, AudioManager.OnAudioFocusChangeListener {
|
|
|
|
public static final String ACTION_PLAY_CURRENT = "com.minamp.PLAY_CURRENT";
|
|
public static final String ACTION_TOGGLE = "com.minamp.TOGGLE";
|
|
public static final String ACTION_PLAY = "com.minamp.PLAY";
|
|
public static final String ACTION_PAUSE = "com.minamp.PAUSE";
|
|
public static final String ACTION_NEXT = "com.minamp.NEXT";
|
|
public static final String ACTION_PREV = "com.minamp.PREV";
|
|
public static final String ACTION_SEEK = "com.minamp.SEEK";
|
|
public static final String ACTION_STOP = "com.minamp.STOP";
|
|
public static final String ACTION_SLEEP = "com.minamp.SLEEP";
|
|
public static final String EXTRA_POS = "pos";
|
|
public static final String EXTRA_MINUTES = "minutes";
|
|
|
|
public static final int IDLE = 0, BUFFERING = 1, PLAYING = 2, PAUSED = 3;
|
|
|
|
// ---- state shared with the UI (main thread) ----
|
|
private static PlayerService instance;
|
|
private static int state = IDLE;
|
|
private static String status = "";
|
|
private static long sleepAt;
|
|
private static boolean sleepEndOfTrack;
|
|
|
|
public interface Listener { void onPlayerChanged(); }
|
|
private static final ArrayList<Listener> listeners = new ArrayList<Listener>();
|
|
|
|
public static void addListener(Listener l) { if (!listeners.contains(l)) listeners.add(l); }
|
|
public static void removeListener(Listener l) { listeners.remove(l); }
|
|
|
|
public static int state() { return state; }
|
|
public static String status() { return status; }
|
|
public static boolean isPlaying() { return state == PLAYING || state == BUFFERING; }
|
|
public static long sleepAt() { return sleepAt; }
|
|
public static boolean sleepEndOfTrack() { return sleepEndOfTrack; }
|
|
|
|
public static long position() {
|
|
PlayerService s = instance;
|
|
if (s != null) return s.currentPosition();
|
|
return PlayQueue.get().resumePositionMs;
|
|
}
|
|
|
|
public static long duration() {
|
|
Item it = PlayQueue.get().current();
|
|
return it == null ? 0 : it.duration;
|
|
}
|
|
|
|
/** Human-readable "where is this streaming from" for the Now Playing screen. */
|
|
public static String sourceLabel() {
|
|
PlayerService s = instance;
|
|
if (s == null || s.track == null) return "";
|
|
if (s.localFile) return s.getString(R.string.downloaded);
|
|
App app = App.get();
|
|
String where = app.server == null || app.server.active() == null ? "" : com.minamp.plex.Server.kindLabel(app.server.active().kind);
|
|
String q = s.transcoding ? s.kbps + " kbps MP3" : (s.track.codec.toUpperCase() + (s.track.bitrate > 0 ? " " + s.track.bitrate + " kbps" : ""));
|
|
return where.length() > 0 ? where + " · " + q : q;
|
|
}
|
|
|
|
public static void send(Context c, String action) {
|
|
c.startService(new Intent(c, PlayerService.class).setAction(action));
|
|
}
|
|
|
|
public static void playCurrent(Context c, long posMs) {
|
|
c.startService(new Intent(c, PlayerService.class).setAction(ACTION_PLAY_CURRENT).putExtra(EXTRA_POS, posMs));
|
|
}
|
|
|
|
public static void seekTo(Context c, long posMs) {
|
|
c.startService(new Intent(c, PlayerService.class).setAction(ACTION_SEEK).putExtra(EXTRA_POS, posMs));
|
|
}
|
|
|
|
// ---- instance ----
|
|
private final ExecutorService worker = com.minamp.net.Threads.pool("player", 1);
|
|
private final ExecutorService reporter = com.minamp.net.Threads.pool("report", 1);
|
|
private StreamProxy proxy;
|
|
private MediaPlayer mp;
|
|
private boolean prepared;
|
|
private int gen;
|
|
private Item track;
|
|
private long offsetMs; // transcodes restart at an offset; MediaPlayer counts from 0
|
|
private long pendingPos; // position to show / seek to before prepared
|
|
private boolean playWhenReady;
|
|
private boolean transcoding;
|
|
private boolean fromFile;
|
|
private int kbps;
|
|
private String session;
|
|
private String routeId;
|
|
private int retries;
|
|
private int[] variantOrder; // transcode variant behind each URL of the current route
|
|
private int acceptedVariant = -1;
|
|
private String refusal; // what the server said when it refused the current stream
|
|
private int refusalCode;
|
|
private int failuresInRow;
|
|
private boolean forceDownload;
|
|
private boolean scrobbled;
|
|
private long lastTimeline;
|
|
private long lastSave;
|
|
private long lastPos; // last position seen while playing (getCurrentPosition is unreliable after completion)
|
|
private boolean localFile; // playing a download from the SD card
|
|
private long stallPos = -1; // position last seen moving
|
|
private long stallSince;
|
|
private boolean stallWarned;
|
|
|
|
private AudioManager audio;
|
|
private boolean hasFocus;
|
|
private boolean pausedByFocus;
|
|
private boolean pausedByCall;
|
|
private WifiManager.WifiLock wifiLock;
|
|
private boolean noisyRegistered;
|
|
private boolean foreground;
|
|
|
|
@Override
|
|
public void onCreate() {
|
|
super.onCreate();
|
|
App.applyLanguage(this);
|
|
instance = this;
|
|
audio = (AudioManager) getSystemService(AUDIO_SERVICE);
|
|
audio.registerMediaButtonEventReceiver(new ComponentName(this, MediaButtonReceiver.class));
|
|
WifiManager wm = (WifiManager) getSystemService(WIFI_SERVICE);
|
|
wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL, "minamp");
|
|
wifiLock.setReferenceCounted(false);
|
|
proxy = new StreamProxy();
|
|
proxy.listener = new StreamProxy.Listener() {
|
|
public void onError(final String id, final int code, final String message) {
|
|
post(new Runnable() {
|
|
public void run() {
|
|
if (id.equals(routeId)) { refusal = message; refusalCode = code; }
|
|
}
|
|
});
|
|
}
|
|
public void onAccepted(final String id, final int index) {
|
|
post(new Runnable() {
|
|
public void run() { if (id.equals(routeId)) rememberVariant(index); }
|
|
});
|
|
}
|
|
};
|
|
File cache = null;
|
|
try {
|
|
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) cache = getExternalCacheDir();
|
|
} catch (Exception ignored) {}
|
|
proxy.cacheDir = cache != null ? cache : getCacheDir();
|
|
try { proxy.start(); } catch (IOException e) { /* reported when playback starts */ }
|
|
try {
|
|
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
|
|
tm.listen(phoneListener, PhoneStateListener.LISTEN_CALL_STATE);
|
|
} catch (Exception ignored) {}
|
|
App.get().handler.post(ticker);
|
|
}
|
|
|
|
@Override
|
|
public void onDestroy() {
|
|
App.get().handler.removeCallbacks(ticker);
|
|
try {
|
|
TelephonyManager tm = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
|
|
tm.listen(phoneListener, PhoneStateListener.LISTEN_NONE);
|
|
} catch (Exception ignored) {}
|
|
if (track != null) PlayQueue.get().savePosition(currentPosition());
|
|
releasePlayer();
|
|
proxy.shutdown();
|
|
abandonFocus();
|
|
unregisterNoisy();
|
|
if (wifiLock.isHeld()) wifiLock.release();
|
|
audio.unregisterMediaButtonEventReceiver(new ComponentName(this, MediaButtonReceiver.class));
|
|
worker.shutdownNow();
|
|
reporter.shutdown();
|
|
instance = null;
|
|
state = IDLE;
|
|
notifyListeners();
|
|
super.onDestroy();
|
|
}
|
|
|
|
@Override
|
|
public IBinder onBind(Intent intent) { return null; }
|
|
|
|
@Override
|
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
|
if (intent != null) handle(intent);
|
|
return START_NOT_STICKY;
|
|
}
|
|
|
|
private void handle(Intent i) {
|
|
String a = i.getAction();
|
|
if (a == null) return;
|
|
PlayQueue q = PlayQueue.get();
|
|
if (ACTION_PLAY_CURRENT.equals(a)) {
|
|
newTrack();
|
|
startCurrent(i.getLongExtra(EXTRA_POS, 0), true);
|
|
} else if (ACTION_TOGGLE.equals(a)) {
|
|
if (state == PLAYING || state == BUFFERING) pause(); else play();
|
|
} else if (ACTION_PLAY.equals(a)) {
|
|
play();
|
|
} else if (ACTION_PAUSE.equals(a)) {
|
|
pause();
|
|
} else if (ACTION_NEXT.equals(a)) {
|
|
if (q.advance(true)) { newTrack(); startCurrent(0, true); }
|
|
} else if (ACTION_PREV.equals(a)) {
|
|
if (currentPosition() > 4000 && track != null) { seek(0); }
|
|
else if (q.back()) { newTrack(); startCurrent(0, true); }
|
|
} else if (ACTION_SEEK.equals(a)) {
|
|
seek(i.getLongExtra(EXTRA_POS, 0));
|
|
} else if (ACTION_STOP.equals(a)) {
|
|
stopAll(null);
|
|
} else if (ACTION_SLEEP.equals(a)) {
|
|
int m = i.getIntExtra(EXTRA_MINUTES, 0);
|
|
sleepEndOfTrack = m < 0;
|
|
sleepAt = m > 0 ? System.currentTimeMillis() + m * 60000L : 0;
|
|
notifyListeners();
|
|
}
|
|
if (state == IDLE && track == null) stopSelf();
|
|
}
|
|
|
|
private void newTrack() {
|
|
retries = 0;
|
|
forceDownload = false;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ playback pipeline
|
|
|
|
private void startCurrent(final long posMs, boolean play) {
|
|
final Item it = PlayQueue.get().current();
|
|
final App app = App.get();
|
|
if (it == null || app.library == null) { stopAll(null); return; }
|
|
final int g = ++gen;
|
|
releasePlayer();
|
|
track = it;
|
|
scrobbled = false;
|
|
pendingPos = posMs;
|
|
offsetMs = 0;
|
|
lastPos = posMs;
|
|
playWhenReady = play;
|
|
setState(BUFFERING, getString(R.string.connecting));
|
|
goForeground();
|
|
if (play) requestFocus();
|
|
// Downloaded: play the file on the SD card, no network needed (offline too)
|
|
final File saved = Downloads.get().fileFor(it);
|
|
localFile = saved != null;
|
|
if (saved != null) {
|
|
transcoding = false;
|
|
session = null;
|
|
variantOrder = null;
|
|
kbps = 0;
|
|
begin(new java.util.ArrayList<String>(), "mp3", saved, 0, posMs);
|
|
return;
|
|
}
|
|
final boolean download = forceDownload || app.downloadFirst();
|
|
worker.execute(new Runnable() {
|
|
public void run() {
|
|
try {
|
|
Library lib = app.library;
|
|
lib.ensureMedia(it);
|
|
lib.server.base(); // choose LAN / remote / relay before deciding quality
|
|
final int max = app.currentMaxKbps();
|
|
final boolean direct = Library.canDirectPlay(it, max);
|
|
final String sess = direct ? null : "minamp-" + Long.toString(System.currentTimeMillis(), 36);
|
|
// Streamed transcodes start at the offset; downloads always fetch the whole track and seek in the file.
|
|
final long startAt = (direct || download) ? 0 : (posMs / 1000) * 1000;
|
|
final java.util.List<String> urls;
|
|
final int[] order = direct ? null : Library.variantOrder();
|
|
if (direct) { urls = new java.util.ArrayList<String>(); urls.add(lib.directUrl(it)); }
|
|
else urls = lib.transcodeUrls(it, max, startAt, sess);
|
|
final String ext = direct ? extFor(it) : "mp3";
|
|
File file = null;
|
|
if (download) file = downloadTo(urls, order, g, it);
|
|
final File ff = file;
|
|
post(new Runnable() {
|
|
public void run() {
|
|
if (g != gen) return;
|
|
transcoding = !direct;
|
|
session = sess;
|
|
variantOrder = order;
|
|
kbps = direct ? 0 : (max <= 0 ? 320 : max);
|
|
begin(urls, ext, ff, startAt, (direct || download) ? posMs : 0);
|
|
}
|
|
});
|
|
} catch (final Throwable e) {
|
|
if (!(e instanceof Exception)) App.logError("player", e);
|
|
final String m = com.minamp.net.Threads.describe(e);
|
|
final int code = e instanceof com.minamp.net.RefusedException ? ((com.minamp.net.RefusedException) e).code : 0;
|
|
post(new Runnable() {
|
|
public void run() {
|
|
if (g != gen) return;
|
|
if (code > 0) { refusal = m; refusalCode = code; }
|
|
trackFailed(m);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private static String extFor(Item it) {
|
|
String c = it.container.toLowerCase();
|
|
if ("mp4".equals(c) || "m4a".equals(c) || "m4b".equals(c)) return "m4a";
|
|
if ("ogg".equals(c)) return "ogg";
|
|
return "mp3";
|
|
}
|
|
|
|
private File downloadTo(java.util.List<String> urls, final int[] order, int g, Item it) throws IOException {
|
|
File base = null;
|
|
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) base = getExternalCacheDir();
|
|
if (base == null) base = getCacheDir();
|
|
File f = new File(base, "play-" + (g % 2) + ".tmp");
|
|
Http.Response r = null;
|
|
String why = null;
|
|
int lastCode = 0;
|
|
for (int i = 0; i < urls.size(); i++) {
|
|
r = Http.get(urls.get(i), App.get().server != null ? App.get().server.mediaHeaders() : Plex.headers(null), 30000);
|
|
if (r.code == 200) {
|
|
if (order != null) {
|
|
final int idx = i;
|
|
post(new Runnable() { public void run() { if (variantOrder == order) rememberVariant(idx); else rememberVariantOf(order, idx); } });
|
|
}
|
|
break;
|
|
}
|
|
why = com.minamp.util.Tr.t("refused", "Server refused the stream (%1$d)", r.code);
|
|
String body = new String(r.readAll(), "UTF-8");
|
|
if (body.trim().length() > 0) why += ": " + Plex.snippet(body);
|
|
lastCode = r.code;
|
|
r = null;
|
|
}
|
|
if (r == null) throw new com.minamp.net.RefusedException(lastCode, why);
|
|
InputStream in = r.body;
|
|
FileOutputStream out = new FileOutputStream(f);
|
|
try {
|
|
byte[] buf = new byte[16384];
|
|
long total = r.contentLength > 0 ? r.contentLength : (it.duration > 0 ? it.duration / 1000 * 24000 : 0);
|
|
long got = 0, lastUi = 0;
|
|
int n;
|
|
while ((n = in.read(buf)) > 0) {
|
|
if (g != gen) { Http.abort(r); throw new IOException("cancelled"); }
|
|
out.write(buf, 0, n);
|
|
got += n;
|
|
if (total > 0 && got - lastUi > 65536) {
|
|
lastUi = got;
|
|
final int pct = (int) Math.min(99, got * 100 / total);
|
|
final int fg = g;
|
|
post(new Runnable() { public void run() { if (fg == gen) setState(BUFFERING, getString(R.string.downloading_pct, pct)); } });
|
|
}
|
|
}
|
|
} finally {
|
|
out.close();
|
|
r.close();
|
|
}
|
|
return f;
|
|
}
|
|
|
|
private void rememberVariant(int index) {
|
|
rememberVariantOf(variantOrder, index);
|
|
}
|
|
|
|
private void rememberVariantOf(int[] order, int index) {
|
|
if (order == null || index < 0 || index >= order.length) return;
|
|
int v = order[index];
|
|
if (order == variantOrder) acceptedVariant = v;
|
|
com.minamp.net.DiagLog.log("server accepted transcode variant " + v + " (" + Library.VARIANT_NAMES[v] + ")");
|
|
if (v != Library.preferredVariant) {
|
|
Library.preferredVariant = v;
|
|
App.get().prefs.edit().putInt("tcVariant", v).commit();
|
|
}
|
|
}
|
|
|
|
private void begin(java.util.List<String> urls, String ext, File file, long offset, long seekTo) {
|
|
try {
|
|
mp = new MediaPlayer();
|
|
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
|
|
mp.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK);
|
|
mp.setOnPreparedListener(this);
|
|
mp.setOnCompletionListener(this);
|
|
mp.setOnErrorListener(this);
|
|
prepared = false;
|
|
fromFile = file != null;
|
|
offsetMs = offset;
|
|
pendingPos = seekTo;
|
|
if (fromFile) {
|
|
FileInputStream fis = new FileInputStream(file);
|
|
mp.setDataSource(fis.getFD());
|
|
fis.close();
|
|
} else {
|
|
if (proxy.port() < 0) proxy.start();
|
|
routeId = proxy.newId();
|
|
refusal = null;
|
|
refusalCode = 0;
|
|
acceptedVariant = -1;
|
|
// transcodes are fetched once and buffered, so player reconnects can't restart them
|
|
mp.setDataSource(proxy.register(routeId, urls, ext, transcoding,
|
|
App.get().server != null ? App.get().server.mediaHeaders() : null));
|
|
}
|
|
setState(BUFFERING, getString(R.string.buffering));
|
|
mp.prepareAsync();
|
|
} catch (Exception e) {
|
|
trackFailed(message(e));
|
|
}
|
|
}
|
|
|
|
public void onPrepared(MediaPlayer p) {
|
|
if (p != mp) return;
|
|
prepared = true;
|
|
failuresInRow = 0;
|
|
if (pendingPos > 0) {
|
|
try { mp.seekTo((int) pendingPos); } catch (Exception ignored) {}
|
|
}
|
|
pendingPos = 0;
|
|
if (playWhenReady && (hasFocus || requestFocus())) {
|
|
mp.start();
|
|
setState(PLAYING, "");
|
|
if (!wifiLock.isHeld()) wifiLock.acquire();
|
|
registerNoisy();
|
|
} else {
|
|
setState(PAUSED, "");
|
|
}
|
|
goForeground();
|
|
report(true);
|
|
}
|
|
|
|
public void onCompletion(MediaPlayer p) {
|
|
if (p != mp) return;
|
|
long pos = lastPos;
|
|
Item it = track;
|
|
// A stream that ends well before the track does means the network dropped.
|
|
if (it != null && it.duration > 0 && pos > 0 && pos < it.duration - 15000 && !fromFile) {
|
|
trackFailed(getString(R.string.stream_interrupted));
|
|
return;
|
|
}
|
|
if (it != null) {
|
|
if (!scrobbled) scrobble(it);
|
|
reportState(it, "stopped", it.duration);
|
|
}
|
|
if (sleepEndOfTrack) {
|
|
sleepEndOfTrack = false;
|
|
PlayQueue.get().advance(false);
|
|
stopAll(getString(R.string.sleep_timer));
|
|
return;
|
|
}
|
|
if (PlayQueue.get().advance(false)) {
|
|
newTrack();
|
|
startCurrent(0, true);
|
|
} else {
|
|
PlayQueue.get().savePosition(0);
|
|
stopAll(null);
|
|
}
|
|
}
|
|
|
|
public boolean onError(MediaPlayer p, int what, int extra) {
|
|
if (p != mp) return true;
|
|
trackFailed(getString(R.string.playback_error, what, extra));
|
|
return true;
|
|
}
|
|
|
|
private void trackFailed(String msg) {
|
|
long pos = Math.max(currentPosition(), lastPos);
|
|
if (refusal != null) msg = refusal; // the server's own reason beats "Playback error (1/-1004)"
|
|
boolean permanent = refusalCode >= 400 && refusalCode < 500 && refusalCode != 408 && refusalCode != 429;
|
|
refusal = null;
|
|
refusalCode = 0;
|
|
retries = permanent ? 99 : retries + 1;
|
|
App app = App.get();
|
|
if (retries == 1) {
|
|
// Most failures on a phone are network changes: re-pick the connection and resume.
|
|
if (app.server != null) app.server.invalidate();
|
|
startCurrent(pos, true);
|
|
} else if (retries == 2 && !forceDownload && !app.downloadFirst()) {
|
|
forceDownload = true; // some old media stacks choke on streams but play files fine
|
|
startCurrent(pos, true);
|
|
} else {
|
|
failuresInRow++;
|
|
retries = 0;
|
|
forceDownload = false;
|
|
toast(msg);
|
|
if (failuresInRow >= 3 || !PlayQueue.get().advance(true)) {
|
|
failuresInRow = 0;
|
|
stopAll(msg);
|
|
} else {
|
|
startCurrent(0, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ controls
|
|
|
|
private void play() {
|
|
if (mp != null && prepared) {
|
|
playWhenReady = true;
|
|
if (!hasFocus && !requestFocus()) return;
|
|
mp.start();
|
|
setState(PLAYING, "");
|
|
if (!wifiLock.isHeld()) wifiLock.acquire();
|
|
registerNoisy();
|
|
goForeground();
|
|
report(true);
|
|
} else if (state == BUFFERING) {
|
|
playWhenReady = true;
|
|
} else if (PlayQueue.get().current() != null) {
|
|
newTrack();
|
|
startCurrent(PlayQueue.get().resumePositionMs, true);
|
|
}
|
|
}
|
|
|
|
private void pause() {
|
|
pausedByFocus = false;
|
|
playWhenReady = false;
|
|
if (mp != null && prepared) {
|
|
try { mp.pause(); } catch (Exception ignored) {}
|
|
setState(PAUSED, "");
|
|
PlayQueue.get().savePosition(currentPosition());
|
|
report(true);
|
|
} else if (state == BUFFERING) {
|
|
setState(BUFFERING, status);
|
|
}
|
|
if (wifiLock.isHeld()) wifiLock.release();
|
|
unregisterNoisy();
|
|
goForeground();
|
|
}
|
|
|
|
private void seek(long ms) {
|
|
if (track == null) return;
|
|
if (ms < 0) ms = 0;
|
|
if (track.duration > 0 && ms > track.duration - 1000) ms = Math.max(0, track.duration - 1000);
|
|
if (mp != null && prepared && (!transcoding || fromFile || proxy.isComplete(routeId))) {
|
|
try { mp.seekTo((int) ms); } catch (Exception ignored) {}
|
|
lastPos = ms;
|
|
notifyListeners();
|
|
} else {
|
|
// transcoded streams can't seek: restart the transcode at the new offset
|
|
startCurrent(ms, playWhenReady || state == PLAYING);
|
|
}
|
|
}
|
|
|
|
private void stopAll(String message) {
|
|
if (track != null) {
|
|
reportState(track, "stopped", currentPosition());
|
|
PlayQueue.get().savePosition(state == IDLE ? 0 : currentPosition());
|
|
}
|
|
gen++;
|
|
releasePlayer();
|
|
track = null;
|
|
sleepAt = 0;
|
|
setState(IDLE, message == null ? "" : message);
|
|
abandonFocus();
|
|
unregisterNoisy();
|
|
if (wifiLock.isHeld()) wifiLock.release();
|
|
stopForeground(true);
|
|
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).cancel(1);
|
|
foreground = false;
|
|
stopSelf();
|
|
}
|
|
|
|
private void releasePlayer() {
|
|
if (routeId != null) { proxy.unregister(routeId); routeId = null; }
|
|
if (session != null && acceptedVariant >= 0) {
|
|
final String s = Library.variantSession(session, acceptedVariant);
|
|
final App app = App.get();
|
|
reporter.execute(new Runnable() { public void run() { if (app.library != null) app.library.stopTranscode(s); } });
|
|
}
|
|
session = null;
|
|
acceptedVariant = -1;
|
|
if (mp != null) {
|
|
try { mp.reset(); } catch (Exception ignored) {}
|
|
try { mp.release(); } catch (Exception ignored) {}
|
|
mp = null;
|
|
}
|
|
prepared = false;
|
|
}
|
|
|
|
private long currentPosition() {
|
|
if (mp != null && prepared) {
|
|
try { return offsetMs + mp.getCurrentPosition(); } catch (Exception e) { return offsetMs; }
|
|
}
|
|
return pendingPos + offsetMs;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ ticker / reporting
|
|
|
|
private final Runnable ticker = new Runnable() {
|
|
public void run() {
|
|
App.get().handler.postDelayed(this, 1000);
|
|
long now = System.currentTimeMillis();
|
|
if (sleepAt > 0 && now >= sleepAt) {
|
|
sleepAt = 0;
|
|
pause();
|
|
toast(getString(R.string.sleep_paused));
|
|
}
|
|
if (state == PLAYING && track != null) {
|
|
long pos = currentPosition();
|
|
lastPos = pos;
|
|
// "playing" but not moving for 12 s: say so instead of staying silent
|
|
if (pos != stallPos) {
|
|
stallPos = pos;
|
|
stallSince = now;
|
|
stallWarned = false;
|
|
} else if (!stallWarned && now - stallSince > 12000) {
|
|
stallWarned = true;
|
|
com.minamp.net.DiagLog.log("playback stalled at " + pos / 1000 + "s");
|
|
toast(getString(R.string.stalled));
|
|
}
|
|
if (!scrobbled && track.duration > 0 && pos > track.duration * 9 / 10) scrobble(track);
|
|
if (now - lastTimeline > 10000) report(false);
|
|
if (now - lastSave > 15000) { lastSave = now; PlayQueue.get().resumePositionMs = pos; }
|
|
notifyListeners();
|
|
}
|
|
}
|
|
};
|
|
|
|
private void report(boolean force) {
|
|
if (track == null) return;
|
|
lastTimeline = System.currentTimeMillis();
|
|
String s = state == PLAYING ? "playing" : state == PAUSED ? "paused" : "buffering";
|
|
reportState(track, s, currentPosition());
|
|
}
|
|
|
|
private void reportState(final Item it, final String s, final long pos) {
|
|
final App app = App.get();
|
|
reporter.execute(new Runnable() {
|
|
public void run() { if (app.library != null) app.library.timeline(it, s, pos); }
|
|
});
|
|
}
|
|
|
|
private void scrobble(final Item it) {
|
|
scrobbled = true;
|
|
final App app = App.get();
|
|
reporter.execute(new Runnable() {
|
|
public void run() {
|
|
if (app.library == null) return;
|
|
// offline (a download): keep the play and report it when the server is back
|
|
boolean online = app.library.server.active() != null;
|
|
if (!online) {
|
|
try { app.library.server.base(); online = true; } catch (Exception ignored) {}
|
|
}
|
|
if (online) app.library.scrobble(it);
|
|
else app.addPendingScrobble(it.ratingKey);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------ focus, calls, noisy
|
|
|
|
private boolean requestFocus() {
|
|
if (hasFocus) return true;
|
|
hasFocus = audio.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
|
== AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
|
|
return hasFocus;
|
|
}
|
|
|
|
private void abandonFocus() {
|
|
if (hasFocus) audio.abandonAudioFocus(this);
|
|
hasFocus = false;
|
|
}
|
|
|
|
public void onAudioFocusChange(int change) {
|
|
switch (change) {
|
|
case AudioManager.AUDIOFOCUS_GAIN:
|
|
hasFocus = true;
|
|
if (mp != null) try { mp.setVolume(1f, 1f); } catch (Exception ignored) {}
|
|
if (pausedByFocus) { pausedByFocus = false; play(); }
|
|
break;
|
|
case AudioManager.AUDIOFOCUS_LOSS:
|
|
hasFocus = false;
|
|
pause();
|
|
break;
|
|
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
|
if (state == PLAYING) { pause(); pausedByFocus = true; }
|
|
break;
|
|
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
|
if (mp != null) try { mp.setVolume(0.2f, 0.2f); } catch (Exception ignored) {}
|
|
break;
|
|
}
|
|
}
|
|
|
|
private final PhoneStateListener phoneListener = new PhoneStateListener() {
|
|
@Override
|
|
public void onCallStateChanged(int s, String number) {
|
|
if (s == TelephonyManager.CALL_STATE_RINGING || s == TelephonyManager.CALL_STATE_OFFHOOK) {
|
|
if (state == PLAYING || state == BUFFERING) { pause(); pausedByCall = true; }
|
|
} else if (s == TelephonyManager.CALL_STATE_IDLE && pausedByCall) {
|
|
pausedByCall = false;
|
|
play();
|
|
}
|
|
}
|
|
};
|
|
|
|
private final BroadcastReceiver noisy = new BroadcastReceiver() {
|
|
@Override public void onReceive(Context c, Intent i) { pause(); }
|
|
};
|
|
|
|
private void registerNoisy() {
|
|
if (noisyRegistered) return;
|
|
registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
|
|
noisyRegistered = true;
|
|
}
|
|
|
|
private void unregisterNoisy() {
|
|
if (!noisyRegistered) return;
|
|
try { unregisterReceiver(noisy); } catch (Exception ignored) {}
|
|
noisyRegistered = false;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ notification / state
|
|
|
|
@SuppressWarnings("deprecation")
|
|
private void goForeground() {
|
|
Item it = track != null ? track : PlayQueue.get().current();
|
|
if (it == null) return;
|
|
Notification n = new Notification(R.drawable.ic_stat, null, 0);
|
|
n.flags |= Notification.FLAG_ONGOING_EVENT | Notification.FLAG_NO_CLEAR;
|
|
Intent open = new Intent(this, NowPlayingActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
PendingIntent pi = PendingIntent.getActivity(this, 0, open, PendingIntent.FLAG_UPDATE_CURRENT);
|
|
String line = (state == PAUSED ? getString(R.string.paused_dot) : "") + it.artist;
|
|
n.setLatestEventInfo(this, it.title, line, pi);
|
|
if (state == PAUSED || state == IDLE) {
|
|
// let Android reclaim us while paused, but keep the notification as a shortcut back
|
|
if (foreground) { stopForeground(false); foreground = false; }
|
|
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).notify(1, n);
|
|
} else {
|
|
startForeground(1, n);
|
|
foreground = true;
|
|
}
|
|
}
|
|
|
|
private void setState(int s, String msg) {
|
|
state = s;
|
|
status = msg == null ? "" : msg;
|
|
notifyListeners();
|
|
}
|
|
|
|
private static void notifyListeners() {
|
|
for (int i = listeners.size() - 1; i >= 0; i--) {
|
|
if (i < listeners.size()) listeners.get(i).onPlayerChanged();
|
|
}
|
|
}
|
|
|
|
private void post(Runnable r) { App.get().handler.post(r); }
|
|
|
|
private void toast(String m) {
|
|
if (m != null && m.length() > 0) Toast.makeText(this, m, m.length() > 40 ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show();
|
|
}
|
|
|
|
static String message(Exception e) {
|
|
String m = e.getMessage();
|
|
if (m == null || m.length() == 0) m = e.getClass().getSimpleName();
|
|
return m;
|
|
}
|
|
}
|