Podlite 1.12: a build for Android 4.1 (Xperia J) with ExoPlayer, speed and skip silences
- One build per Android level, as in Tenda and Plexamp Lite: build/Podlite.apk (Android 2.2+, versionCode 1308) and build/Podlite-api16.apk (Android 4.1+, 1316), from shared src/ plus platform/api8 or platform/api16; build.sh now runs on the local toolchain (aapt2, dx or R8, signapk.py; the JKS key converted for the run). - Android 4.1 build: ExoPlayer 2.18.7 behind an Engine interface, with playback speed (player header) and skipping silences (Settings), a 3-minute buffer and MediaPlayer as fallback for audio it can't decode; a notification and lock screen with the cover and buttons; Holo; a menu button on phones without a Menu key; metered networks for "download on Wi-Fi only". - Both builds: tlskit's Http.abort fix (a finished response's pooled connection is no longer closed under a later request); the lock screen's and newer headsets' play/pause keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b2ca212c45
commit
666a7eb8cd
32 files changed
+1267
-123
No files matched your search
@@ -0,0 +1,133 @@
|
||||
package com.miniabs;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.DefaultLoadControl;
|
||||
import com.google.android.exoplayer2.ExoPlayer;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.Renderer;
|
||||
import com.google.android.exoplayer2.RenderersFactory;
|
||||
import com.google.android.exoplayer2.Tracks;
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes;
|
||||
import com.google.android.exoplayer2.audio.AudioRendererEventListener;
|
||||
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer;
|
||||
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory;
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector;
|
||||
import com.google.android.exoplayer2.metadata.MetadataOutput;
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||
import com.google.android.exoplayer2.text.TextOutput;
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
|
||||
import com.google.android.exoplayer2.video.VideoRendererEventListener;
|
||||
|
||||
/**
|
||||
* ExoPlayer 2.18 (Android 4.1+ build): audio only, decoded with Android's MediaCodec. Over MediaPlayer it brings
|
||||
* playback speed and skipping silences (its own audio processing, so it works on Android 4.1), a buffer of minutes,
|
||||
* and errors that say what failed. Audio focus, unplugging and the wake/Wi-Fi locks stay with PlayerService.
|
||||
*/
|
||||
final class ExoEngine implements Engine {
|
||||
|
||||
private final ExoPlayer player;
|
||||
private Listener listener;
|
||||
private boolean preparedSent;
|
||||
private boolean failed;
|
||||
|
||||
ExoEngine(final Context c) {
|
||||
RenderersFactory audioOnly = new RenderersFactory() {
|
||||
public Renderer[] createRenderers(Handler h, VideoRendererEventListener v, AudioRendererEventListener a,
|
||||
TextOutput t, MetadataOutput m) {
|
||||
return new Renderer[] {new MediaCodecAudioRenderer(c, MediaCodecSelector.DEFAULT, h, a)};
|
||||
}
|
||||
};
|
||||
DefaultHttpDataSource.Factory http = new DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("Podlite").setConnectTimeoutMs(15000).setReadTimeoutMs(30000)
|
||||
.setAllowCrossProtocolRedirects(true);
|
||||
ProgressiveMediaSource.Factory sources = new ProgressiveMediaSource.Factory(new DefaultDataSource.Factory(c, http),
|
||||
new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true));
|
||||
player = new ExoPlayer.Builder(c, audioOnly, sources)
|
||||
// up to 3 minutes ahead in memory: rides out Wi-Fi gaps that MediaPlayer's seconds of buffer couldn't
|
||||
.setLoadControl(new DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(60000, 180000, 1500, 3000)
|
||||
.setPrioritizeTimeOverSizeThresholds(true)
|
||||
.build())
|
||||
.setAudioAttributes(new AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build(), false)
|
||||
.setHandleAudioBecomingNoisy(false)
|
||||
.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
.setLooper(Looper.getMainLooper())
|
||||
.build();
|
||||
player.addListener(new Player.Listener() {
|
||||
public void onPlaybackStateChanged(int state) {
|
||||
if (listener == null || failed) return;
|
||||
if (state == Player.STATE_READY && !preparedSent) {
|
||||
preparedSent = true;
|
||||
listener.onPrepared(ExoEngine.this);
|
||||
} else if (state == Player.STATE_ENDED) {
|
||||
listener.onCompletion(ExoEngine.this);
|
||||
}
|
||||
}
|
||||
|
||||
public void onPlayerError(PlaybackException e) {
|
||||
if (listener == null || failed) return;
|
||||
failed = true;
|
||||
int code = e.errorCode;
|
||||
// 4xxx: decoding; 3003: a container this player can't read. Android's MediaPlayer may manage.
|
||||
boolean decoder = (code >= 4000 && code < 5000) || code == PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED;
|
||||
Throwable cause = e.getCause();
|
||||
String why = cause != null && cause.getMessage() != null ? cause.getMessage() : e.getMessage();
|
||||
listener.onError(ExoEngine.this, "Erro de reprodución: " + e.getErrorCodeName() + (why != null ? " (" + why + ")" : ""), decoder);
|
||||
}
|
||||
|
||||
public void onTracksChanged(Tracks tracks) {
|
||||
// ExoPlayer plays silence through audio it can't decode instead of failing: fail, so MediaPlayer can try
|
||||
if (listener == null || failed || tracks.isEmpty() || tracks.isTypeSupported(C.TRACK_TYPE_AUDIO, true)) return;
|
||||
failed = true;
|
||||
listener.onError(ExoEngine.this, "Este teléfono non pode descodificar este audio.", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setListener(Listener l) { listener = l; }
|
||||
|
||||
public void setSource(File file, String url) {
|
||||
player.setMediaItem(MediaItem.fromUri(file != null ? Uri.fromFile(file) : Uri.parse(url)));
|
||||
}
|
||||
|
||||
public void prepare() { player.prepare(); }
|
||||
|
||||
public void start() { player.setPlayWhenReady(true); }
|
||||
|
||||
public void pause() { player.setPlayWhenReady(false); }
|
||||
|
||||
public void seekTo(int ms) { player.seekTo(ms); }
|
||||
|
||||
public int position() { return (int) player.getCurrentPosition(); }
|
||||
|
||||
public int duration() {
|
||||
long d = player.getDuration();
|
||||
return d == C.TIME_UNSET || d < 0 ? 0 : (int) d;
|
||||
}
|
||||
|
||||
public int bufferedPercent() { return player.getBufferedPercentage(); }
|
||||
|
||||
public void setVolume(float v) { player.setVolume(v); }
|
||||
|
||||
public void setSpeed(float speed, boolean skipSilence) {
|
||||
player.setPlaybackParameters(new PlaybackParameters(speed));
|
||||
player.setSkipSilenceEnabled(skipSilence);
|
||||
}
|
||||
|
||||
public void release() {
|
||||
listener = null;
|
||||
player.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.miniabs;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.media.AudioManager;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.media.RemoteControlClient;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.os.StrictMode;
|
||||
import android.view.View;
|
||||
import android.view.ViewConfiguration;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
/**
|
||||
* What differs between Podlite's builds, for Android 4.1 and later (Xperia J, Galaxy S II...): ExoPlayer (speed,
|
||||
* skipping silences), a notification with the cover and buttons, lock-screen controls, metered networks, and the
|
||||
* ⋮ button on phones without a Menu key. platform/api8 has the same methods for Android 2.2; keep the two in step.
|
||||
*/
|
||||
final class Platform {
|
||||
private Platform() {}
|
||||
|
||||
static final int API = 16;
|
||||
|
||||
static final boolean SPEED = true;
|
||||
|
||||
static final int COVER_PX = 320; // the lock screen shows it large
|
||||
|
||||
private static boolean initDone;
|
||||
|
||||
static void init(Context c) {
|
||||
if (initDone) return;
|
||||
initDone = true;
|
||||
// Shared code sometimes closes a connection on the main thread (always brief). Android 2.x allows that;
|
||||
// Android 3+ kills apps that target it. Allow it here too.
|
||||
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder(StrictMode.getThreadPolicy()).permitNetwork().build());
|
||||
}
|
||||
|
||||
static Engine newEngine(Context c, boolean fallback) {
|
||||
return fallback ? new MediaPlayerEngine(c) : new ExoEngine(c);
|
||||
}
|
||||
|
||||
/** Android 4.1 knows which networks cost: mobile data, and a Wi-Fi that is a phone's hotspot. */
|
||||
static boolean unmetered(Context c) {
|
||||
try {
|
||||
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
|
||||
return ni != null && ni.isConnected() && !cm.isActiveNetworkMetered();
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean needsMenuButton(Context c) {
|
||||
return !ViewConfiguration.get(c).hasPermanentMenuKey();
|
||||
}
|
||||
|
||||
/** Cover, title and show with −15 ⏯ +30 and ✕ (pulled down, a larger one). Swiping it away while paused stops. */
|
||||
static Notification playerNotification(Context c, Episode ep, String what, Bitmap cover, int state, boolean ongoing) {
|
||||
Notification n = new Notification.Builder(c)
|
||||
.setSmallIcon(R.drawable.ic_stat)
|
||||
.setContent(views(c, R.layout.notif_player, ep, what, cover, state))
|
||||
.setContentIntent(openPlayer(c))
|
||||
.setDeleteIntent(service(c, PlayerService.A_STOP, 14))
|
||||
.setOngoing(ongoing)
|
||||
.setWhen(0)
|
||||
.setPriority(state == PlayerService.PLAYING ? Notification.PRIORITY_HIGH : Notification.PRIORITY_DEFAULT)
|
||||
.build();
|
||||
n.bigContentView = views(c, R.layout.notif_player_big, ep, what, cover, state);
|
||||
return n;
|
||||
}
|
||||
|
||||
private static Bitmap small, smallOf;
|
||||
|
||||
private static RemoteViews views(Context c, int layout, Episode ep, String what, Bitmap cover, int state) {
|
||||
RemoteViews v = new RemoteViews(c.getPackageName(), layout);
|
||||
v.setTextViewText(R.id.n_title, ep.title);
|
||||
v.setTextViewText(R.id.n_sub, Util.join(what, ep.show));
|
||||
if (cover != null) {
|
||||
// the size the notification shows it at: a full-size bitmap would be copied to the status bar each time
|
||||
if (cover != smallOf) {
|
||||
int px = (int) (112 * c.getResources().getDisplayMetrics().density);
|
||||
smallOf = cover;
|
||||
small = cover.getWidth() <= px ? cover : Bitmap.createScaledBitmap(cover, px, px * cover.getHeight() / cover.getWidth(), true);
|
||||
}
|
||||
v.setImageViewBitmap(R.id.n_cover, small);
|
||||
}
|
||||
Prefs p = Prefs.get(c);
|
||||
v.setTextViewText(R.id.n_back, "−" + p.skipBack());
|
||||
v.setTextViewText(R.id.n_fwd, "+" + p.skipFwd());
|
||||
boolean playing = state == PlayerService.PLAYING || state == PlayerService.LOADING;
|
||||
v.setImageViewResource(R.id.n_play, playing ? R.drawable.ic_pause_small : R.drawable.ic_play_small);
|
||||
v.setOnClickPendingIntent(R.id.n_back, service(c, PlayerService.A_BACK, 11));
|
||||
v.setOnClickPendingIntent(R.id.n_play, service(c, PlayerService.A_TOGGLE, 12));
|
||||
v.setOnClickPendingIntent(R.id.n_fwd, service(c, PlayerService.A_FWD, 13));
|
||||
v.setOnClickPendingIntent(R.id.n_close, service(c, PlayerService.A_STOP, 14));
|
||||
v.setOnClickPendingIntent(R.id.n_cover, openPlayer(c));
|
||||
v.setOnClickPendingIntent(R.id.n_text, openPlayer(c));
|
||||
v.setViewVisibility(R.id.n_close, View.VISIBLE);
|
||||
return v;
|
||||
}
|
||||
|
||||
static PendingIntent openPlayer(Context c) {
|
||||
Intent i = new Intent(c, PlayerActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
return PendingIntent.getActivity(c, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
|
||||
private static PendingIntent service(Context c, String action, int request) {
|
||||
return PendingIntent.getService(c, request, new Intent(c, PlayerService.class).setAction(action), 0);
|
||||
}
|
||||
|
||||
private static RemoteControlClient remote;
|
||||
private static String remoteFor;
|
||||
private static Bitmap remoteCover;
|
||||
|
||||
/** The lock screen's player: cover, title, show and buttons (⏮ ⏭ skip back and forward: media keys). */
|
||||
static void lockScreen(Context c, Episode ep, Bitmap cover, int state) {
|
||||
AudioManager am = (AudioManager) c.getSystemService(Context.AUDIO_SERVICE);
|
||||
if (ep == null || state == PlayerService.IDLE) {
|
||||
if (remote != null) {
|
||||
remote.setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED);
|
||||
am.unregisterRemoteControlClient(remote);
|
||||
remote = null;
|
||||
remoteFor = null;
|
||||
remoteCover = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (remote == null) {
|
||||
ComponentName buttons = new ComponentName(c, MediaButtonReceiver.class);
|
||||
am.registerMediaButtonEventReceiver(buttons);
|
||||
Intent i = new Intent(Intent.ACTION_MEDIA_BUTTON).setComponent(buttons);
|
||||
remote = new RemoteControlClient(PendingIntent.getBroadcast(c.getApplicationContext(), 0, i, 0));
|
||||
remote.setTransportControlFlags(RemoteControlClient.FLAG_KEY_MEDIA_PLAY | RemoteControlClient.FLAG_KEY_MEDIA_PAUSE
|
||||
| RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE | RemoteControlClient.FLAG_KEY_MEDIA_NEXT
|
||||
| RemoteControlClient.FLAG_KEY_MEDIA_PREVIOUS);
|
||||
am.registerRemoteControlClient(remote);
|
||||
}
|
||||
remote.setPlaybackState(state == PlayerService.PLAYING ? RemoteControlClient.PLAYSTATE_PLAYING
|
||||
: state == PlayerService.LOADING ? RemoteControlClient.PLAYSTATE_BUFFERING
|
||||
: state == PlayerService.ERROR ? RemoteControlClient.PLAYSTATE_ERROR : RemoteControlClient.PLAYSTATE_PAUSED);
|
||||
if (ep.id.equals(remoteFor) && cover == remoteCover) return;
|
||||
remoteFor = ep.id;
|
||||
remoteCover = cover;
|
||||
RemoteControlClient.MetadataEditor ed = remote.editMetadata(true);
|
||||
ed.putString(MediaMetadataRetriever.METADATA_KEY_TITLE, ep.title);
|
||||
ed.putString(MediaMetadataRetriever.METADATA_KEY_ARTIST, ep.show);
|
||||
ed.putLong(MediaMetadataRetriever.METADATA_KEY_DURATION, (long) (ep.duration * 1000));
|
||||
// a copy: Android recycles the artwork it's given when the next one comes, and this one is in the cover cache
|
||||
if (cover != null) ed.putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, cover.copy(Bitmap.Config.ARGB_8888, false));
|
||||
ed.apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android 4.1 build: Holo (dark, like the app), without the action bar: the screens draw their own header. -->
|
||||
<resources>
|
||||
<style name="BaseTheme" parent="@android:style/Theme.Holo.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.miniabs;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
|
||||
/**
|
||||
* What differs between Podlite's builds, for Android 2.2 and later (the Galaxy Mini). platform/api16 has the same
|
||||
* methods for Android 4.1 and later; the rest of the app is shared and calls these. Keep the two in step.
|
||||
*/
|
||||
final class Platform {
|
||||
private Platform() {}
|
||||
|
||||
/** The Android level this build is for: its minSdkVersion. */
|
||||
static final int API = 8;
|
||||
|
||||
/** The player can change speed and skip silences (Android 2.x's can't). */
|
||||
static final boolean SPEED = false;
|
||||
|
||||
/** Size the cover is loaded at for the notification and lock screen. */
|
||||
static final int COVER_PX = 64;
|
||||
|
||||
/** At the start of each screen and service (main thread). */
|
||||
static void init(Context c) {}
|
||||
|
||||
/** The player for an episode. {@code fallback}: the episode failed to decode in the usual one. */
|
||||
static Engine newEngine(Context c, boolean fallback) {
|
||||
return new MediaPlayerEngine(c);
|
||||
}
|
||||
|
||||
/** On a network that doesn't cost by the megabyte (for "download only on Wi-Fi"): here, Wi-Fi. */
|
||||
static boolean unmetered(Context c) {
|
||||
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
|
||||
return ni != null && ni.isConnected() && ni.getType() == ConnectivityManager.TYPE_WIFI;
|
||||
}
|
||||
|
||||
/** The phone has no Menu key, so screens need a ⋮ button. Android 2.x phones all have one. */
|
||||
static boolean needsMenuButton(Context c) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The playback notification. Android 2.x: title and state; its notifications can't have buttons. */
|
||||
@SuppressWarnings("deprecation")
|
||||
static Notification playerNotification(Context c, Episode ep, String what, Bitmap cover, int state, boolean ongoing) {
|
||||
Notification n = new Notification(R.drawable.ic_stat, null, 0);
|
||||
n.flags |= ongoing ? Notification.FLAG_ONGOING_EVENT : 0;
|
||||
n.setLatestEventInfo(c, ep.title, Util.join(what, ep.show), openPlayer(c));
|
||||
return n;
|
||||
}
|
||||
|
||||
static PendingIntent openPlayer(Context c) {
|
||||
Intent i = new Intent(c, PlayerActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
return PendingIntent.getActivity(c, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
|
||||
/** The lock screen's player (null episode or IDLE: take it away). Android 2.x has none. */
|
||||
static void lockScreen(Context c, Episode ep, Bitmap cover, int state) {}
|
||||
}
|
||||
Reference in new issue
Block a user