Plexamp Lite 1.14: Android 4.1 build with ExoPlayer, lyrics, home-screen widget

- One build per Android level, as in Tenda: build/PlexampLite.apk (Android 2.2+, versionCode 1508) and
  build/PlexampLite-api16.apk (Android 4.1+, 1516), from shared src/ plus platform/api8 or platform/api16.
- Android 4.1 build: ExoPlayer 2.18.7 behind a small Engine interface (gapless hand-over to read-ahead and
  downloaded files, a longer buffer, MediaPlayer fallback for tracks it can't decode), a notification and
  lock screen with art and buttons, a resizable widget, Holo, a menu button without a Menu key, metered networks.
  R8 shrinks ExoPlayer and Guava (tools/fetch_exoplayer.sh, tools/r8-rules.pro).
- Both builds: lyrics in Now Playing as in the Mac app (timed lyrics follow and seek), a home-screen widget,
  media play/pause keys, the moon icon drawn without clipPath.
- PlexampLite-noreport on the SD card stops play reports, for tests against a real server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XoséandClaude Opus 5.5 committed 2026-11-01 18:41:26 +01:00
1 parent 2e7476669e
commit 13f29e26c4
39 files changed
+1685 -119

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
package com.minamp;
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.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
* gapless hand-over to a queued file, a buffer of minutes instead of seconds, and errors that say what failed.
* Audio focus, headphone unplugging and the wake/Wi-Fi locks stay with PlayerService, as for MediaPlayer.
*/
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)};
}
};
// the loopback StreamProxy: it never takes long to answer, but may wait on the server for data
DefaultHttpDataSource.Factory http = new DefaultHttpDataSource.Factory()
.setUserAgent("PlexampLite").setConnectTimeoutMs(8000).setReadTimeoutMs(30000);
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 (a few MB of compressed audio): rides out Wi-Fi gaps that
// MediaPlayer's few 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_MUSIC).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, e.getErrorCodeName() + (why != null ? ": " + why : ""), decoder);
}
public void onTracksChanged(Tracks tracks) {
// ExoPlayer plays silence through a track it can't decode instead of failing: fail, so another try can
if (listener == null || failed || tracks.isEmpty() || tracks.isTypeSupported(C.TRACK_TYPE_AUDIO, true)) return;
failed = true;
listener.onError(ExoEngine.this, "no decoder on this phone for this audio", true);
}
public void onMediaItemTransition(MediaItem item, int reason) {
if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || listener == null) return;
// the queued file took over: forget the finished one
int i = player.getCurrentMediaItemIndex();
if (i > 0) player.removeMediaItems(0, i);
listener.onGaplessNext(ExoEngine.this);
}
});
}
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(long ms) { player.seekTo(ms); }
public long position() { return player.getCurrentPosition(); }
public void setVolume(float v) { player.setVolume(v); }
public boolean canSeek() { return player.isCurrentMediaItemSeekable(); }
public boolean supportsNext() { return true; }
public void setNext(File file) {
int after = player.getCurrentMediaItemIndex() + 1;
if (player.getMediaItemCount() > after) player.removeMediaItems(after, player.getMediaItemCount());
if (file != null) player.addMediaItem(MediaItem.fromUri(Uri.fromFile(file)));
}
public void release() {
listener = null;
player.release();
}
public String name() { return "ExoPlayer"; }
}
+122
View File
@@ -0,0 +1,122 @@
package com.minamp;
import android.app.Notification;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
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.Bundle;
import android.os.StrictMode;
import android.view.ViewConfiguration;
import com.minamp.plex.Item;
/**
* What differs between Plexamp Lite's builds, for Android 4.1 and later (Xperia J, Galaxy S II...): ExoPlayer
* (gapless, steadier buffering), a notification with art and buttons, lock-screen controls, a resizable widget,
* and metered networks. 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 int ART_PX = 320; // the lock screen shows it large
static void init(App app) {
// The shared code sometimes closes a connection or cuts a download short 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 metered(Context c) {
try {
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo ni = cm.getActiveNetworkInfo();
return ni != null && ni.isConnected() && cm.isActiveNetworkMetered();
} catch (Exception e) {
return false;
}
}
static boolean needsMenuButton(Context c) {
return !ViewConfiguration.get(c).hasPermanentMenuKey();
}
/** Art, title and artist with ⏮ ⏯ ⏭ and ×; pulled down, a larger view. Swiping it away while paused stops. */
static Notification playerNotification(Context c, Item it, Bitmap art, int state) {
boolean playing = state == PlayerService.PLAYING || state == PlayerService.BUFFERING;
Notification n = new Notification.Builder(c)
.setSmallIcon(R.drawable.ic_stat)
.setContent(PlayerViews.build(c, R.layout.widget_player, it, art, state, true))
.setContentIntent(PlayerViews.open(c))
.setDeleteIntent(PlayerViews.service(c, PlayerService.ACTION_STOP, 14))
.setOngoing(playing)
.setWhen(0)
.setPriority(playing ? Notification.PRIORITY_HIGH : Notification.PRIORITY_DEFAULT)
.build();
n.bigContentView = PlayerViews.build(c, R.layout.widget_player_large, it, art, state, true);
return n;
}
private static RemoteControlClient remote;
private static String remoteKey;
private static Bitmap remoteArt;
/** The lock screen's player: art, title, artist and buttons (they arrive as media buttons). */
static void lockScreen(Context c, Item it, Bitmap art, int state) {
AudioManager am = (AudioManager) c.getSystemService(Context.AUDIO_SERVICE);
if (it == null || state == PlayerService.IDLE) {
if (remote != null) {
remote.setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED);
am.unregisterRemoteControlClient(remote);
remote = null;
remoteKey = null;
remoteArt = 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.PAUSED ? RemoteControlClient.PLAYSTATE_PAUSED : RemoteControlClient.PLAYSTATE_BUFFERING);
if (it.ratingKey.equals(remoteKey) && art == remoteArt) return;
remoteKey = it.ratingKey;
remoteArt = art;
RemoteControlClient.MetadataEditor ed = remote.editMetadata(true);
ed.putString(MediaMetadataRetriever.METADATA_KEY_TITLE, it.title);
ed.putString(MediaMetadataRetriever.METADATA_KEY_ARTIST, it.artist);
ed.putString(MediaMetadataRetriever.METADATA_KEY_ALBUM, it.album);
ed.putLong(MediaMetadataRetriever.METADATA_KEY_DURATION, it.duration);
// a copy: Android recycles the artwork it's given when the next one comes, and this one is in the art cache
if (art != null) ed.putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, art.copy(Bitmap.Config.ARGB_8888, false));
ed.apply();
}
/** Two rows tall or more: the larger layout. */
static int widgetLayout(AppWidgetManager m, int id) {
Bundle o = m.getAppWidgetOptions(id);
int h = o == null ? 0 : o.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
return h >= 100 ? R.layout.widget_player_large : R.layout.widget_player;
}
}
+5
View File
@@ -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 top bar. -->
<resources>
<style name="BaseTheme" parent="@android:style/Theme.Holo.NoActionBar" />
</resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Android 4.1 build: resizable; two rows or more show the larger layout (Platform.widgetLayout). -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="40dp"
android:minResizeWidth="180dp"
android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0"
android:previewImage="@drawable/ic_launcher"
android:initialLayout="@layout/widget_player" />
+66
View File
@@ -0,0 +1,66 @@
package com.minamp;
import android.app.Notification;
import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import com.minamp.plex.Item;
/**
* What differs between Plexamp Lite'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;
/** Size the current track's art is loaded at for the notification, lock screen and widget, in pixels. */
static final int ART_PX = 96;
/** At start-up, before anything else. */
static void init(App app) {}
/** The player for a track. {@code fallback}: the track failed to decode in the usual one. */
static Engine newEngine(Context c, boolean fallback) {
return new MediaPlayerEngine(c);
}
/** On a network that may cost by the megabyte (the "away" quality applies): here, anything but Wi-Fi. */
static boolean metered(Context c) {
try {
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo ni = cm.getActiveNetworkInfo();
return ni != null && ni.getType() != ConnectivityManager.TYPE_WIFI;
} catch (Exception e) {
return false;
}
}
/** The phone has no Menu key, so screens need a ⋮ button for the options menu. Android 2.x phones all have one. */
static boolean needsMenuButton(Context c) {
return false;
}
/** The playback notification. Android 2.x: title and artist; its notifications can't have buttons. */
@SuppressWarnings("deprecation")
static Notification playerNotification(Context c, Item it, Bitmap art, int state) {
Notification n = new Notification(R.drawable.ic_stat, null, 0);
n.flags |= Notification.FLAG_ONGOING_EVENT | Notification.FLAG_NO_CLEAR;
String line = (state == PlayerService.PAUSED ? c.getString(R.string.paused_dot) : "") + it.artist;
n.setLatestEventInfo(c, it.title, line, PlayerViews.open(c));
return n;
}
/** The lock screen's player (null item or IDLE: take it away). Android 2.x has none. */
static void lockScreen(Context c, Item it, Bitmap art, int state) {}
/** The widget's layout for its current size. Android 2.x widgets have one size. */
static int widgetLayout(AppWidgetManager m, int id) {
return R.layout.widget_player;
}
}