From 13f29e26c408f0ddc647d021666a1541fb6377ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sun, 1 Nov 2026 18:41:26 +0100 Subject: [PATCH] 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 --- AndroidManifest.xml | 13 +- README.md | 63 ++++++- build.sh | 117 +++++++++---- platform/api16/com/minamp/ExoEngine.java | 141 ++++++++++++++++ platform/api16/com/minamp/Platform.java | 122 ++++++++++++++ platform/api16/res/values/themes.xml | 5 + platform/api16/res/xml/widget_player.xml | 11 ++ platform/api8/com/minamp/Platform.java | 66 ++++++++ res/drawable/widget_bg.xml | 6 + res/layout/activity_now_playing.xml | 6 + res/layout/widget_player.xml | 39 +++++ res/layout/widget_player_large.xml | 53 ++++++ res/values-en/strings.xml | 8 + res/values/dimens.xml | 2 + res/values/strings.xml | 8 + res/values/styles.xml | 2 +- res/values/themes.xml | 6 + res/xml/widget_player.xml | 8 + spec.md | 30 +++- src/com/minamp/App.java | 16 +- src/com/minamp/BaseActivity.java | 15 ++ src/com/minamp/Engine.java | 54 ++++++ src/com/minamp/Glyph.java | 43 ++++- src/com/minamp/Lyrics.java | 116 +++++++++++++ src/com/minamp/LyricsView.java | 162 ++++++++++++++++++ src/com/minamp/MediaButtonReceiver.java | 4 + src/com/minamp/MediaPlayerEngine.java | 71 ++++++++ src/com/minamp/NowPlayingActivity.java | 26 ++- src/com/minamp/PlayerService.java | 199 +++++++++++++++++------ src/com/minamp/PlayerViews.java | 83 ++++++++++ src/com/minamp/PlayerWidget.java | 57 +++++++ src/com/minamp/SetupActivity.java | 3 + src/com/minamp/plex/Item.java | 14 ++ src/com/minamp/plex/Library.java | 36 ++++ src/com/minamp/util/Lrc.java | 95 +++++++++++ test/LyricsTest.java | 40 +++++ tools/fetch_exoplayer.sh | 35 ++++ tools/r8-rules.pro | 17 ++ tools/strings.py | 12 ++ 39 files changed, 1685 insertions(+), 119 deletions(-) create mode 100644 platform/api16/com/minamp/ExoEngine.java create mode 100644 platform/api16/com/minamp/Platform.java create mode 100644 platform/api16/res/values/themes.xml create mode 100644 platform/api16/res/xml/widget_player.xml create mode 100644 platform/api8/com/minamp/Platform.java create mode 100644 res/drawable/widget_bg.xml create mode 100644 res/layout/widget_player.xml create mode 100644 res/layout/widget_player_large.xml create mode 100644 res/values/themes.xml create mode 100644 res/xml/widget_player.xml create mode 100644 src/com/minamp/Engine.java create mode 100644 src/com/minamp/Lyrics.java create mode 100644 src/com/minamp/LyricsView.java create mode 100644 src/com/minamp/MediaPlayerEngine.java create mode 100644 src/com/minamp/PlayerViews.java create mode 100644 src/com/minamp/PlayerWidget.java create mode 100644 src/com/minamp/util/Lrc.java create mode 100644 test/LyricsTest.java create mode 100755 tools/fetch_exoplayer.sh create mode 100644 tools/r8-rules.pro diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 2552af7..7e1c628 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,10 @@ + @@ -55,6 +57,13 @@ + + + + + + + diff --git a/README.md b/README.md index 09fa410..09d1455 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Plexamp Lite A small Plexamp-style music player for Plex, built for the Samsung Galaxy Mini (GT-S5570: -Android 2.2/2.3, 240×320 ldpi screen, 600 MHz ARMv6, 384 MB RAM). Runs on any Android 2.2+ phone. -Formerly Minamp (the package name, `com.minamp`, stays so updates install over it). +Android 2.2/2.3, 240×320 ldpi screen, 600 MHz ARMv6, 384 MB RAM). Runs on any Android 2.2+ phone, and comes +in a second build for Android 4.1 and later (Xperia J, Galaxy S II…) that uses what those phones can do: see +*Android 4.1 build*. Formerly Minamp (the package name, `com.minamp`, stays so updates install over it). ## Language @@ -14,7 +15,7 @@ log stays in English since it's meant for troubleshooting. ## Install 1. On the phone: **Settings → Applications → Unknown sources** (tick it). -2. Copy `PlexampLite.apk` to the SD card (USB cable, or download it in the browser) and open it +2. Copy `PlexampLite.apk` (Android 4.1+: `PlexampLite-api16.apk`; Tenda picks the right one by itself) to the SD card (USB cable, or download it in the browser) and open it with a file manager, or from a computer: `adb install PlexampLite.apk`. 3. Plexamp Lite installs to internal storage (about 1 MB): an app on the SD card is killed whenever the phone shares its SD card over USB, which stopped the music. @@ -71,7 +72,9 @@ clock has been reset to before 2024 the certificate date check is skipped rather * A Bluetooth speaker that drops its link for a moment pauses Plexamp Lite, which resumes by itself if the speaker is back within 20 s. Why playback paused or stopped is written to the connection log, which also goes to logcat (tag `PlexampLite`, readable over USB with `adb logcat`). -* Plays are reported to Plex (Now Playing in the Plex dashboard, play counts, resume position). +* Plays are reported to Plex (Now Playing in the Plex dashboard, play counts, resume position). For testing + against a real server, an empty file named `PlexampLite-noreport` at the top of the SD card turns that off + (`adb shell touch /sdcard/PlexampLite-noreport`; delete it afterwards). ## Features @@ -82,6 +85,12 @@ clock has been reset to before 2024 the certificate date check is skipped rather * Long-press anything: Play next, Add to queue, Go to album/artist, Love. * Now Playing: big art on a backdrop coloured from the cover, seek bar, shuffle, repeat (queue/track), love, sleep timer (15–90 min or end of track), where it's streaming from. +* **Lyrics**: the microphone button in Now Playing shows the track's lyrics from Plex in place of the art (as in + the Mac app). It's a lasting setting: it stays on for the following tracks and after restarts. Timed (LRC) + lyrics follow the song with the current line lit (tap a line to jump there; scrolling by hand pauses the + follow for a few seconds); plain-text lyrics just scroll. With several versions, timed ones win, else the + longest text (a local `.txt` can be a broken copy). +* **Home-screen widget** (4×1): art, title, artist, previous / play-pause / next; tap the art for Now Playing. * Up Next queue: jump, play next, remove, clear; the queue and position survive restarts. * Downloads for offline listening: long-press a track, album, artist or playlist -> *Download*; *Menu -> Downloads* shows progress, space used and free, play/shuffle, retry and remove. Files go to @@ -92,15 +101,47 @@ clock has been reset to before 2024 the certificate date check is skipped rather * Wired headset button: 1 click play/pause, 2 next, 3 previous; Bluetooth media keys; pauses for calls and when headphones are unplugged. +## Android 4.1 build + +One build per **Android level**, never per phone (the same pattern as Tenda): `build/PlexampLite.apk` for +Android 2.2+ and `build/PlexampLite-api16.apk` for 4.1+, from the same source, package and key. +`versionCode` is the manifest's × 100 + the level (15 → 1508 and 1516), so a phone that moves up a level +gets the newer build as an ordinary update, and Tenda gives each phone the build for its Android. Everything is +shared in `src/`; what only one level can do is in `platform/api8/…/Platform.java` and +`platform/api16/…/Platform.java` (the same methods; keep them in step), plus `platform/api16/res`, which +overrides a few resources. The Android 4.1 build adds: + +* **ExoPlayer 2.18** (`platform/api16/…/ExoEngine.java`) instead of Android's MediaPlayer, behind the small + `Engine` interface the player service uses (`MediaPlayerEngine` on Android 2.x): + * **gapless playback**: when the next track's file is already here (read ahead or downloaded), it's queued + in the player and starts with no gap; + * a buffer of up to 3 minutes instead of seconds, so Wi-Fi gaps don't silence it; + * errors that say what failed. A track ExoPlayer can't decode is retried with Android's MediaPlayer. + Read-ahead, the stream proxy, the transcodes and the recovery steps are the same in both builds. +* A **notification** with the art and ⏮ ⏯ ⏭ × (pull it down for a larger one); swiping it away while paused stops. +* **Lock-screen controls** with the art (RemoteControlClient). +* The **widget** can be resized; two rows or taller shows a larger layout. +* Android 4's **Holo** dialogs and text fields; a ⋮ button on phones without a Menu key. +* **Metered networks**: a phone's Wi-Fi hotspot counts as away, like mobile data. + +The Galaxy Mini's build is unchanged in how it plays, and also got the widget and the lyrics. + ## Building (no Android SDK needed) ``` tools/setup_toolchain.sh # once: fetches aapt2, the API 8 android.jar, builds dx, renames Bouncy Castle -./build.sh # -> build/PlexampLite.apk +tools/fetch_exoplayer.sh # once: ExoPlayer 2.18.7 + Guava (checksums pinned) into ~/android-toolchain +./build.sh # -> build/PlexampLite.apk (Android 2.2+) and build/PlexampLite-api16.apk (4.1+) +./build.sh 16 # one level only ``` -`build.sh` runs `aapt2` (resources), `javac` (Java 8 bytecode checked against the API 8 -`android.jar`), `dx` (dex 035) and `tools/signapk.py`, which writes the v1 signature in the +Also needed for the Android 4.1 build: the API 16 `android.jar` in `~/android-toolchain/platforms/android-16/`, +R8 (`d8.jar` from build-tools 34, in `~/android-toolchain/calendar-tools/`) and a JDK 11+ (`build.sh` finds +Homebrew's openjdk@17). ExoPlayer 2.18.7 is the newest that needs only Guava; R8 (`tools/r8-rules.pro`) keeps +the app whole and shrinks ExoPlayer and Guava to what it uses, in one dex. + +Per level `build.sh` runs `aapt2` (resources), `javac` (Java 8 bytecode checked against that level's +`android.jar`), then for Android 2.x the Dalvik 2.x link check and `dx` (dex 035), for 4.1 R8; then `tools/signapk.py`, which writes the v1 signature in the classic Android SignApk format and aligns the APK. (Modern `jarsigner` output does *not* install on Android 4.2 and older: it names digests `SHA-1-Digest` and labels the PKCS#7 signer algorithm in a way old `JarUtils` can't read, so the phone treats the APK as unsigned.) The debug key in `signing/` (password `minamp123`) is what this APK is @@ -113,6 +154,8 @@ Tests (desktop JVM, no device): untrusted-root rejection, browsing, search, artwork, and the stream proxy (Range requests over TLS, de-chunking transcodes, aborting). * `test/QueueTest.java`: shuffle/unshuffle, repeat modes, play next, remove, move, clear. +* `test/LyricsTest.java`: LRC (several stamps a line, offsets, word stamps) and plain-text lyrics + (`javac -d /tmp/lt src/com/minamp/util/Lrc.java test/LyricsTest.java && java -cp /tmp/lt LyricsTest`). * `test/PreferHomeTest.java` + `test/identity_server.py`: after being away, the home address wins again as soon as it answers. * `test/NetTest.java`: the bundled TLS + HTTP client against real servers; `test/HandshakeProfile.java` @@ -120,5 +163,7 @@ Tests (desktop JVM, no device): ## Limits -* No gapless playback (Android 2.2/2.3 have no API for it). -* Notification has no playback buttons (pre-Android 3 can't); tap it to open Now Playing. +* Android 2.x build: no gapless playback (no API for it), and the notification has no playback buttons + (pre-Android 3 can't): tap it to open Now Playing, or use the widget. +* Android 4.1 build: gapless only into a track whose file is already on the phone; FLAC is still transcoded + (ExoPlayer has no FLAC decoder on Android 4.1). diff --git a/build.sh b/build.sh index 6109a67..a19396e 100755 --- a/build.sh +++ b/build.sh @@ -1,46 +1,99 @@ #!/bin/bash -# Builds PlexampLite.apk for Android 2.2+ without the Android SDK/Gradle: -# aapt2 (resources) -> javac (Java 8 bytecode vs android-8 API) -> dx (dex 035) -> signapk.py (v1/SHA-1 + zipalign). +# Builds Plexamp Lite without the Android SDK or Gradle, one APK per Android level (same package and key): +# build/PlexampLite.apk Android 2.2 and later (Galaxy Mini) src + platform/api8, against android-8 +# build/PlexampLite-api16.apk Android 4.1 and later (Xperia J...) src + platform/api16 + ExoPlayer, against android-16 +# Each: aapt2 (resources; api16 overlays platform/api16/res) -> javac (Java 8 bytecode) -> then +# api8: Dalvik 2.x link check -> dx (dex 035) +# api16: R8 (shrinks ExoPlayer and Guava, desugars Java 8, dex 035 for Android 4.1) +# -> signapk.py (v1 / SHA-1 in the classic SignApk format, plus zipalign). +# versionCode = the manifest's x 100 + the level, so a phone that moves up to Android 4.1 gets the newer build as an update. +# Toolchain: tools/setup_toolchain.sh, plus tools/fetch_exoplayer.sh and R8 (d8.jar from build-tools 34) for api16. +# ./build.sh both +# ./build.sh 16 one level set -euo pipefail cd "$(dirname "$0")" -T=${TOOLS:-$PWD/toolchain} -ANDROID_JAR=$T/platforms/android-8/android.jar +T=${TOOLS:-$HOME/android-toolchain} BC_SRC=$T/bcsrc -OUT=build -rm -rf $OUT && mkdir -p $OUT/res $OUT/gen $OUT/classes +EXO=$T/exoplayer-2.18.7 +R8=${R8:-$T/calendar-tools/d8.jar} # build-tools' d8.jar holds R8 too +PY=${PY:-$T/venv/bin/python} # signapk.py needs the cryptography package +[ -x "$PY" ] || PY=python3 +LEVELS=${*:-8 16} +# a JDK 11+ (R8 needs it): the macOS java stub answers "javac" but can't run it +if ! javac -version >/dev/null 2>&1 || javac -version 2>&1 | grep -q '1\.8'; then + for j in "${JAVA_HOME:-}" /opt/homebrew/opt/openjdk@17 /opt/homebrew/opt/openjdk $(ls -d "$HOME"/tools/jdk-17* 2>/dev/null); do + if [ -n "$j" ] && [ -x "$j/bin/javac" ]; then export JAVA_HOME=$j PATH=$j/bin:$PATH; break; fi + done +fi -echo "== resources" -$T/aapt2 compile --dir res -o $OUT/res/compiled.zip -$T/aapt2 link -o $OUT/base.apk -I $ANDROID_JAR --manifest AndroidManifest.xml \ - --java $OUT/gen -A assets --min-sdk-version 8 --target-sdk-version 10 \ - --no-version-vectors $OUT/res/compiled.zip - -echo "== java" -find src $OUT/gen -name '*.java' > $OUT/sources.txt -javac -nowarn -Xlint:-options -source 8 -target 8 -encoding UTF-8 -g:source,lines \ - -bootclasspath $ANDROID_JAR -sourcepath "src:$BC_SRC:$OUT/gen" \ - -d $OUT/classes @$OUT/sources.txt 2>&1 | grep -v -E '^Picked up|^Note:' || true -test -f $OUT/classes/com/minamp/HomeActivity.class +echo "== strings" +python3 tools/strings.py echo "== text fit check (240x320, all languages and sizes)" if [ -f "$T/fonts/droidsans-regular.ttf" ]; then - python3 tools/check_fit.py "$T/fonts" || { echo "Some texts don't fit their space (see above)"; exit 1; } + "$PY" tools/check_fit.py "$T/fonts" || { echo "Some texts don't fit their space (see above)"; exit 1; } else echo "(skipped: run tools/setup_toolchain.sh to fetch the Droid Sans font)" fi -echo "== Dalvik 2.x link check" -python3 tools/scan_dalvik.py $OUT/classes $ANDROID_JAR > $OUT/dalvik-scan.txt -if ! grep -q "^total patterns: 0$" $OUT/dalvik-scan.txt; then - cat $OUT/dalvik-scan.txt; echo "Calls Android 2.x can't link (see above): fix before shipping"; exit 1 -fi +CODE=$(sed -n 's/.*android:versionCode="\([0-9]*\)".*/\1/p' AndroidManifest.xml) -echo "== dex" -java -Xmx2g -jar $T/dx.jar --dex --output=$OUT/classes.dex $OUT/classes 2>&1 | grep -v '^Picked up' || true +for API in $LEVELS; do + case $API in + 8) TARGET=10; APK=build/PlexampLite.apk ;; + 16) TARGET=16; APK=build/PlexampLite-api16.apk + [ -f "$EXO/exoplayer-core.jar" ] || { echo "no ExoPlayer in $EXO: run tools/fetch_exoplayer.sh"; exit 1; } + [ -f "$R8" ] || { echo "no R8 at $R8 (build-tools 34's lib/d8.jar): set R8="; exit 1; } ;; + *) echo "no such level: $API (8 or 16)"; exit 1 ;; + esac + ANDROID_JAR=$T/platforms/android-$API/android.jar + OUT=build/api$API + echo "== Android level $API -> $APK (versionCode $((CODE * 100 + API)))" + rm -rf $OUT && mkdir -p $OUT/res $OUT/gen $OUT/classes -echo "== package" -cp $OUT/base.apk $OUT/unsigned.apk -(cd $OUT && zip -q unsigned.apk classes.dex) -# v1 signature in the classic Android SignApk format (jarsigner's modern output won't install on Android <= 4.2) -python3 tools/signapk.py ${KEYSTORE:-$T/minamp.keystore} ${KEYPASS:-minamp123} $OUT/unsigned.apk $OUT/PlexampLite.apk -ls -la $OUT/PlexampLite.apk + sed -e "s/android:minSdkVersion=\"[0-9]*\"/android:minSdkVersion=\"$API\"/" \ + -e "s/android:targetSdkVersion=\"[0-9]*\"/android:targetSdkVersion=\"$TARGET\"/" \ + AndroidManifest.xml > $OUT/AndroidManifest.xml + + $T/aapt2 compile --dir res -o $OUT/res/compiled.zip + OVERLAY= + if [ -d platform/api$API/res ]; then + $T/aapt2 compile --dir platform/api$API/res -o $OUT/res/overlay.zip + OVERLAY="-R $OUT/res/overlay.zip" + fi + $T/aapt2 link -o $OUT/base.apk -I $ANDROID_JAR --manifest $OUT/AndroidManifest.xml \ + --java $OUT/gen -A assets --version-code $((CODE * 100 + API)) --replace-version \ + --no-version-vectors $OUT/res/compiled.zip $OVERLAY + + LIBS= + [ $API -ge 16 ] && LIBS=$(ls $EXO/*.jar | tr '\n' ':') + find src platform/api$API $OUT/gen -name '*.java' > $OUT/sources.txt + javac -nowarn -Xlint:-options -source 8 -target 8 -encoding UTF-8 -g:source,lines \ + -bootclasspath $ANDROID_JAR -classpath "$LIBS" -sourcepath "src:platform/api$API:$BC_SRC:$OUT/gen" \ + -d $OUT/classes @$OUT/sources.txt 2>&1 | grep -v -E '^Picked up|^Note:|^warning: Cannot find annotation|^ reason: class file for|^[0-9]+ warnings?$' || true + test -f $OUT/classes/com/minamp/HomeActivity.class + + if [ $API -lt 11 ]; then + echo "-- Dalvik 2.x link check" + python3 tools/scan_dalvik.py $OUT/classes $ANDROID_JAR > $OUT/dalvik-scan.txt + if ! grep -q "^total patterns: 0$" $OUT/dalvik-scan.txt; then + cat $OUT/dalvik-scan.txt; echo "Calls Android 2.x can't link (see above): fix before shipping"; exit 1 + fi + java -Xmx2g -jar $T/dx.jar --dex --output=$OUT/classes.dex $OUT/classes 2>&1 | grep -v '^Picked up' || true + else + echo "-- R8" + (cd $OUT/classes && zip -q -r ../app.jar .) + PGCONF=$(for f in tools/r8-rules.pro $EXO/*.pro; do printf -- '--pg-conf %s ' "$f"; done) + java -Xmx2g -cp "$R8" com.android.tools.r8.R8 --release --min-api $API --lib $ANDROID_JAR \ + --map-diagnostics info none --output $OUT $PGCONF $OUT/app.jar $EXO/*.jar 2>&1 | grep -v '^Picked up' || true + test -f $OUT/classes.dex + # one dex only: Android 4.1 can't load a second without a multidex library + if [ -f $OUT/classes2.dex ]; then echo "R8 made more than one dex file (over 64K methods)"; exit 1; fi + fi + + cp $OUT/base.apk $OUT/unsigned.apk + (cd $OUT && zip -q unsigned.apk classes.dex) + # v1 signature in the classic Android SignApk format (jarsigner's modern output won't install on Android <= 4.2) + $PY tools/signapk.py ${KEYSTORE:-signing/minamp.keystore} ${KEYPASS:-minamp123} $OUT/unsigned.apk $APK + ls -l $APK +done diff --git a/platform/api16/com/minamp/ExoEngine.java b/platform/api16/com/minamp/ExoEngine.java new file mode 100644 index 0000000..1e064ab --- /dev/null +++ b/platform/api16/com/minamp/ExoEngine.java @@ -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"; } +} diff --git a/platform/api16/com/minamp/Platform.java b/platform/api16/com/minamp/Platform.java new file mode 100644 index 0000000..e9bd3bb --- /dev/null +++ b/platform/api16/com/minamp/Platform.java @@ -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; + } +} diff --git a/platform/api16/res/values/themes.xml b/platform/api16/res/values/themes.xml new file mode 100644 index 0000000..f7cdbae --- /dev/null +++ b/platform/api16/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +