From 666a7eb8cd6cf8f5aa9053088f0cbe2e923f276f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sun, 1 Nov 2026 21:56:18 +0100 Subject: [PATCH] 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 --- AndroidManifest.xml | 6 +- README.md | 38 +++++- build.sh | 138 ++++++++++++++----- platform/api16/com/miniabs/ExoEngine.java | 133 ++++++++++++++++++ platform/api16/com/miniabs/Platform.java | 157 ++++++++++++++++++++++ platform/api16/res/values/themes.xml | 5 + platform/api8/com/miniabs/Platform.java | 63 +++++++++ res/layout/activity_home.xml | 7 + res/layout/activity_player.xml | 21 +++ res/layout/notif_player.xml | 38 ++++++ res/layout/notif_player_big.xml | 46 +++++++ res/values/themes.xml | 6 + res/values/values.xml | 2 +- res/xml/settings.xml | 2 +- spec.md | 15 ++- src/com/miniabs/BaseActivity.java | 23 +++- src/com/miniabs/Covers.java | 20 +++ src/com/miniabs/Dots.java | 31 +++++ src/com/miniabs/DownloadService.java | 8 +- src/com/miniabs/Engine.java | 48 +++++++ src/com/miniabs/MediaButtonReceiver.java | 4 + src/com/miniabs/MediaPlayerEngine.java | 73 ++++++++++ src/com/miniabs/PlayerActivity.java | 38 +++++- src/com/miniabs/PlayerService.java | 119 ++++++++-------- src/com/miniabs/Prefs.java | 8 ++ src/com/miniabs/SettingsActivity.java | 20 +++ src/com/miniabs/net/Http.java | 48 ++++--- tools/check_dex.py | 28 ++++ tools/fetch_exoplayer.sh | 35 +++++ tools/r8-rules.pro | 18 +++ tools/scan_dalvik.py | 46 +++++++ tools/signapk.py | 146 ++++++++++++++++++++ 32 files changed, 1267 insertions(+), 123 deletions(-) create mode 100644 platform/api16/com/miniabs/ExoEngine.java create mode 100644 platform/api16/com/miniabs/Platform.java create mode 100644 platform/api16/res/values/themes.xml create mode 100644 platform/api8/com/miniabs/Platform.java create mode 100644 res/layout/notif_player.xml create mode 100644 res/layout/notif_player_big.xml create mode 100644 res/values/themes.xml create mode 100644 src/com/miniabs/Dots.java create mode 100644 src/com/miniabs/Engine.java create mode 100644 src/com/miniabs/MediaPlayerEngine.java create mode 100644 tools/check_dex.py create mode 100755 tools/fetch_exoplayer.sh create mode 100644 tools/r8-rules.pro create mode 100644 tools/scan_dalvik.py create mode 100644 tools/signapk.py diff --git a/AndroidManifest.xml b/AndroidManifest.xml index c2207f2..5c09baa 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,10 @@ + diff --git a/README.md b/README.md index 2cdb897..1bd2d2c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Podlite -A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570) and other Android 2.2+ phones. Native Java; the APK is about 1.1 MB, nearly all of it the bundled TLS stack. Formerly MiniABS. The interface is in Galician, with large text and touch targets sized for the Mini's 240×320 screen. The launcher icon is the "Podcasts Radio" pack (master in `art/icon-master.png`); `tools/make_icons.py` draws the notification icon (a white radio silhouette) and the player icons. Its warm dark colours and amber accent come from the radio: brass and the glow of its dial. +A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570) and other Android 2.2+ phones, with a +second build for Android 4.1 and later (Xperia J, Galaxy S II…): see *Android 4.1 build*. Native Java; the APK is about 1.1 MB, nearly all of it the bundled TLS stack. Formerly MiniABS. The interface is in Galician, with large text and touch targets sized for the Mini's 240×320 screen. The launcher icon is the "Podcasts Radio" pack (master in `art/icon-master.png`); `tools/make_icons.py` draws the notification icon (a white radio silhouette) and the player icons. Its warm dark colours and amber accent come from the radio: brass and the glow of its dial. ## Install -1. Copy `Podlite.apk` to the phone (USB to the SD card, or download it in the browser). +1. Copy `Podlite.apk` (Android 4.1+: `Podlite-api16.apk`; Tenda picks the right one by itself) to the phone (USB to the SD card, or download it in the browser). 2. On the phone: **Settings → Applications → Unknown sources** → tick it. 3. Open the APK from a file manager and install. Podlite 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 playback and broke HTTPS until a restart. @@ -48,19 +49,44 @@ Colored dots: blue = new, amber = in progress (with a bar), grey = played. Playback opens an Audiobookshelf session and syncs every 15 seconds and on pause, so progress follows you to other apps. An episode only counts as played when playback gets within a minute of its end: a stream that drops or a saved file cut short is retried or reported, never marked as played. If playback says it's playing but stops moving for 12 seconds, Podlite says so. Saved episodes play offline; their positions are kept on the phone and sent to the server next time you're online (or tap refresh on the Saved tab). +## Android 4.1 build + +One build per **Android level**, never per phone (the same pattern as Tenda and Plexamp Lite): `build/Podlite.apk` +for Android 2.2+ and `build/Podlite-api16.apk` for 4.1+, from the same source, package and key. `versionCode` is +the manifest's × 100 + the level (13 → 1308 and 1316), so a phone that moves up a level gets the newer build as an +ordinary update. 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` (Holo). The Android 4.1 build adds: + +- **ExoPlayer 2.18** (`platform/api16/…/ExoEngine.java`) behind the small `Engine` interface the player service + uses (`MediaPlayerEngine` on Android 2.x), with **playback speed** (the button at the top of the player: 0,8× to 2×) + and **skip silences** (Settings → Reprodución). It keeps up to 3 minutes buffered. An episode ExoPlayer can't + decode is retried with Android's own player. +- A **notification** with the cover and −15 ⏯ +30 ✕ (pull it down for a larger one); swiping it away while paused stops. +- **Lock-screen controls** with the cover (⏮ ⏭ skip back and forward). +- Android 4's **Holo** dialogs, menus and Settings; a ⋮ button on phones without a Menu key. +- "Download on Wi-Fi only" means any network that doesn't cost: a phone's Wi-Fi hotspot counts as mobile data. + ## Limits - Episodes are streamed with direct play. Formats Android 2.x can't decode (e.g. Opus) can't be played, since the transcoded HLS stream isn't supported on 2.x. -- No playback speed control: Android 2.x's media player doesn't support it. +- No playback speed control on Android 2.x (its media player doesn't support it); the Android 4.1 build has it. - Downloads need an SD card. "Download on Wi-Fi only" is on by default. ## Building -Needs a JDK, `aapt`, `dalvik-exchange` (dx), `zipalign`, `apksigner` (Ubuntu: `apt install aapt dalvik-exchange zipalign apksigner`), an API 8 `android.jar`, and the renamed Bouncy Castle sources from Plexamp Lite's `tools/setup_toolchain.sh` (`BC_SRC`, default `~/android-toolchain/bcsrc`): +With the toolchain Plexamp Lite's `tools/setup_toolchain.sh` prepares (`~/android-toolchain`: aapt2, dx, the API 8 +jar, Bouncy Castle renamed to `com.minamp.bc`, a Python venv with `cryptography`), plus for the Android 4.1 build the +API 16 jar, ExoPlayer (`tools/fetch_exoplayer.sh`), R8 (`d8.jar` from build-tools 34, in +`~/android-toolchain/calendar-tools/`) and a JDK 11+ (`build.sh` finds Homebrew's openjdk@17). No Gradle, no SDK: - ANDROID_JAR=/path/to/android-8/android.jar ./build.sh + ./build.sh # -> build/Podlite.apk (Android 2.2+) and build/Podlite-api16.apk (4.1+) + ./build.sh 16 # one level only -Compiling against the API 8 jar guarantees no newer Android APIs are used. The APK is signed with `miniabs.keystore` (password `miniabs`); keep it to install updates over the top. +Per level: aapt2 → javac (Java 8 bytecode against that level's `android.jar`) → for Android 2.x the Dalvik 2.x link +check and dx, for 4.1 R8 (`tools/r8-rules.pro`: keeps the app whole, shrinks ExoPlayer and Guava, one dex) → +`tools/signapk.py` (v1, SHA-1, the classic SignApk format Android ≤ 4.2 needs) → `tools/check_dex.py`. The APK is +signed with `miniabs.keystore` (JKS, password and alias `miniabs`; build.sh converts it to a PKCS#12 copy in `build/` +for the run); keep it to install updates over the top. All sizes (text, rows, buttons, covers) are in `res/values/dimens.xml`; change them there and rebuild. diff --git a/build.sh b/build.sh index 9ba9f7d..0eaae25 100755 --- a/build.sh +++ b/build.sh @@ -1,38 +1,104 @@ -#!/bin/sh -# Builds Podlite.apk for Android 2.2+ (API 8) using the plain SDK tools, no Gradle. -# ANDROID_JAR android.jar for API 8 (compiling against it guarantees no newer APIs sneak in) -# KEYSTORE signing keystore (created on first build if missing) -set -e +#!/bin/bash +# Builds Podlite without the Android SDK or Gradle, one APK per Android level (same package and key): +# build/Podlite.apk Android 2.2 and later (Galaxy Mini) src + platform/api8, against android-8 +# build/Podlite-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; Android <= 4.2 rejects jarsigner's). +# 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, default ~/android-toolchain) from Plexamp Lite's tools/setup_toolchain.sh, plus tools/fetch_exoplayer.sh, +# the android-16 jar and R8 (d8.jar from build-tools 34) for api16. +# ./build.sh both +# ./build.sh 16 one level +set -euo pipefail cd "$(dirname "$0")" -ANDROID_JAR=${ANDROID_JAR:-/home/claude/sable/android-platforms/android-8/android.jar} -KEYSTORE=${KEYSTORE:-miniabs.keystore} -BC_SRC=${BC_SRC:-$HOME/android-toolchain/bcsrc} # Bouncy Castle renamed to com.minamp.bc (Minamp's tools/setup_toolchain.sh) -OUT=build - -rm -rf "$OUT" -mkdir -p "$OUT/gen" "$OUT/classes" - -echo "== resources" -aapt package -f -m -J "$OUT/gen" -M AndroidManifest.xml -S res -A assets -I "$ANDROID_JAR" \ - -F "$OUT/Podlite.unsigned.apk" - -echo "== compile (Java 8 bytecode, API 8 class library)" -javac -nowarn -Xlint:-options -encoding UTF-8 -source 8 -target 8 \ - -bootclasspath "$ANDROID_JAR" -classpath "$ANDROID_JAR" -sourcepath "src:$BC_SRC:$OUT/gen" \ - -d "$OUT/classes" $(find src "$OUT/gen" -name '*.java') - -echo "== dex" -dalvik-exchange --dex --min-sdk-version=8 --output="$OUT/classes.dex" "$OUT/classes" -(cd "$OUT" && aapt add Podlite.unsigned.apk classes.dex > /dev/null) - -echo "== align + sign (v1 JAR signature with SHA-1, which Android 2.x requires)" -if [ ! -f "$KEYSTORE" ]; then - keytool -genkeypair -keystore "$KEYSTORE" -storepass miniabs -keypass miniabs -alias miniabs \ - -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Podlite" > /dev/null 2>&1 +T=${TOOLS:-$HOME/android-toolchain} +BC_SRC=$T/bcsrc # Bouncy Castle renamed to com.minamp.bc +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 +KS=${KEYSTORE:-miniabs.keystore} # JKS, password and alias "miniabs": keep it, updates only install over the same key +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 -zipalign -f 4 "$OUT/Podlite.unsigned.apk" "$OUT/Podlite.aligned.apk" -apksigner sign --ks "$KEYSTORE" --ks-pass pass:miniabs --key-pass pass:miniabs --ks-key-alias miniabs \ - --min-sdk-version 8 --v1-signing-enabled true --v2-signing-enabled false --v3-signing-enabled false \ - --out "$OUT/Podlite.apk" "$OUT/Podlite.aligned.apk" -apksigner verify --min-sdk-version 8 "$OUT/Podlite.apk" -ls -l "$OUT/Podlite.apk" + +mkdir -p build +# signapk.py reads PKCS#12: convert the JKS key into build/ for this run (the original stays as it is) +P12=build/signing.p12 +rm -f $P12 +keytool -importkeystore -srckeystore "$KS" -srcstoretype JKS -srcstorepass ${KEYPASS:-miniabs} -srcalias miniabs \ + -destkeystore $P12 -deststoretype PKCS12 -deststorepass ${KEYPASS:-miniabs} -destkeypass ${KEYPASS:-miniabs} \ + -noprompt >/dev/null 2>&1 +trap 'rm -f build/signing.p12' EXIT + +CODE=$(sed -n 's/.*android:versionCode="\([0-9]*\)".*/\1/p' AndroidManifest.xml) + +for API in $LEVELS; do + case $API in + 8) TARGET=10; APK=build/Podlite.apk ;; + 16) TARGET=16; APK=build/Podlite-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 + + # this level's manifest; Android 3.2+ also counts a rotation as a screen size change; Holo settings on 4.1 + sed -e "s/android:minSdkVersion=\"[0-9]*\"/android:minSdkVersion=\"$API\"/" \ + -e "s/android:targetSdkVersion=\"[0-9]*\"/android:targetSdkVersion=\"$TARGET\"/" \ + $( [ $API -ge 13 ] && echo '-e s/keyboardHidden|orientation"/keyboardHidden|orientation|screenSize"/' ) \ + $( [ $API -ge 14 ] && echo '-e s#android:theme="@android:style/Theme"#android:theme="@android:style/Theme.Holo"#' ) \ + 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/miniabs/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 -E '^Picked up|META-INF' || 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) + $PY tools/signapk.py $P12 ${KEYPASS:-miniabs} $OUT/unsigned.apk $APK + python3 tools/check_dex.py $APK + ls -l $APK +done diff --git a/platform/api16/com/miniabs/ExoEngine.java b/platform/api16/com/miniabs/ExoEngine.java new file mode 100644 index 0000000..c09cde4 --- /dev/null +++ b/platform/api16/com/miniabs/ExoEngine.java @@ -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(); + } +} diff --git a/platform/api16/com/miniabs/Platform.java b/platform/api16/com/miniabs/Platform.java new file mode 100644 index 0000000..be0de77 --- /dev/null +++ b/platform/api16/com/miniabs/Platform.java @@ -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(); + } +} diff --git a/platform/api16/res/values/themes.xml b/platform/api16/res/values/themes.xml new file mode 100644 index 0000000..425ba3d --- /dev/null +++ b/platform/api16/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +