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 listeners = new ArrayList(); 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(), "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 urls; final int[] order = direct ? null : Library.variantOrder(); if (direct) { urls = new java.util.ArrayList(); 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 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 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; } }