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
+1678 -112

No files matched your search

+11 -2
View File
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- build.sh makes one APK per Android level from this file: it sets minSdkVersion and targetSdkVersion for each,
and versionCode = the one below x 100 + the level (15 -> 1508 for Android 2.2, 1516 for 4.1). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.minamp"
android:versionCode="14"
android:versionName="1.13"
android:versionCode="15"
android:versionName="1.14"
android:installLocation="internalOnly">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
@@ -55,6 +57,13 @@
<service android:name=".PlayerService" android:exported="false" />
<receiver android:name=".PlayerWidget" android:label="@string/widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_player" />
</receiver>
<receiver android:name=".MediaButtonReceiver">
<intent-filter android:priority="1000">
<action android:name="android.intent.action.MEDIA_BUTTON" />
+54 -9
View File
@@ -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).
+78 -25
View File
@@ -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"
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/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
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
echo "== dex"
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
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
$PY tools/signapk.py ${KEYSTORE:-signing/minamp.keystore} ${KEYPASS:-minamp123} $OUT/unsigned.apk $APK
ls -l $APK
done
+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;
}
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- the widget's and the Android 4.1 notification's card -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#F01D1D22" />
<corners android:radius="6dp" />
</shape>
+6
View File
@@ -23,6 +23,7 @@
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textColor="@color/text" android:textSize="@dimen/text_sub" />
</LinearLayout>
<ImageButton android:id="@+id/np_lyrics_btn" style="@style/IconButton" android:contentDescription="@string/lyrics" />
<ImageButton android:id="@+id/np_queue" style="@style/IconButton" />
</LinearLayout>
@@ -41,6 +42,11 @@
android:layout_height="match_parent"
android:layout_gravity="center"
android:scaleType="centerCrop" />
<com.minamp.LyricsView
android:id="@+id/np_lyrics"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Home-screen widget (4x1) and the Android 4.1 notification. Only views RemoteViews allows; PlayerViews fills it. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rv_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/widget_bg"
android:padding="4dp">
<ImageView android:id="@+id/rv_art"
android:layout_width="56dp" android:layout_height="56dp"
android:scaleType="centerCrop" android:src="@drawable/art_placeholder" />
<LinearLayout android:id="@+id/rv_text"
android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
android:orientation="vertical" android:gravity="center_vertical"
android:paddingLeft="8dp" android:paddingRight="2dp">
<TextView android:id="@+id/rv_title"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:textColor="@color/text" android:textSize="@dimen/text_mini_title" android:textStyle="bold"
android:singleLine="true" android:ellipsize="end" />
<TextView android:id="@+id/rv_sub"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:textColor="@color/text2" android:textSize="@dimen/text_mini_sub"
android:singleLine="true" android:ellipsize="end" />
</LinearLayout>
<ImageButton android:id="@+id/rv_prev" android:layout_width="38dp" android:layout_height="48dp"
android:background="@drawable/bg_pressable_round" android:scaleType="center" android:contentDescription="@string/previous" />
<ImageButton android:id="@+id/rv_toggle" android:layout_width="44dp" android:layout_height="48dp"
android:background="@drawable/bg_pressable_round" android:scaleType="center" android:contentDescription="@string/play_pause" />
<ImageButton android:id="@+id/rv_next" android:layout_width="38dp" android:layout_height="48dp"
android:background="@drawable/bg_pressable_round" android:scaleType="center" android:contentDescription="@string/next" />
<ImageButton android:id="@+id/rv_close" android:layout_width="34dp" android:layout_height="48dp"
android:background="@drawable/bg_pressable_round" android:scaleType="center" android:contentDescription="@string/close"
android:visibility="gone" />
</LinearLayout>
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The larger widget (Android 4.1+, when it's made two rows tall or more) and the expanded Android 4.1 notification. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rv_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:background="@drawable/widget_bg"
android:padding="6dp">
<ImageView android:id="@+id/rv_art"
android:layout_width="112dp" android:layout_height="112dp"
android:layout_gravity="center_vertical"
android:scaleType="centerCrop" android:src="@drawable/art_placeholder" />
<LinearLayout
android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
android:orientation="vertical" android:paddingLeft="10dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"
android:orientation="horizontal">
<LinearLayout android:id="@+id/rv_text"
android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
android:orientation="vertical" android:gravity="center_vertical">
<TextView android:id="@+id/rv_title"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:textColor="@color/text" android:textSize="@dimen/text_body" android:textStyle="bold"
android:maxLines="2" android:ellipsize="end" />
<TextView android:id="@+id/rv_sub"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:textColor="@color/text2" android:textSize="@dimen/text_sub"
android:singleLine="true" android:ellipsize="end" />
<TextView android:id="@+id/rv_album"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:textColor="@color/text2" android:textSize="@dimen/text_mini_sub"
android:singleLine="true" android:ellipsize="end" />
</LinearLayout>
<ImageButton android:id="@+id/rv_close" android:layout_width="34dp" android:layout_height="34dp"
android:background="@drawable/bg_pressable_round" android:scaleType="center"
android:contentDescription="@string/close" android:visibility="gone" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="48dp"
android:orientation="horizontal" android:gravity="center">
<ImageButton android:id="@+id/rv_prev" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1"
android:background="@drawable/bg_pressable_round" android:scaleType="center" android:contentDescription="@string/previous" />
<ImageButton android:id="@+id/rv_toggle" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1"
android:background="@drawable/bg_pressable_round" android:scaleType="center" android:contentDescription="@string/play_pause" />
<ImageButton android:id="@+id/rv_next" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1"
android:background="@drawable/bg_pressable_round" android:scaleType="center" android:contentDescription="@string/next" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
+8
View File
@@ -72,21 +72,26 @@
<string name="loading_albums">Loading albums…</string>
<string name="loading_artists">Loading artists…</string>
<string name="loading_libraries">Loading libraries…</string>
<string name="loading_lyrics">Loading lyrics…</string>
<string name="loading_playlists">Loading playlists…</string>
<string name="log_empty">Nothing logged yet.</string>
<string name="love">Add to favourites</string>
<string name="loved">Added to favourites</string>
<string name="loved_radio">Favourites radio</string>
<string name="loved_radio_sub">Your top rated</string>
<string name="lyrics">Lyrics</string>
<string name="lyrics_failed">Couldn\'t load the lyrics</string>
<string name="min">%1$d min</string>
<string name="music">Music</string>
<string name="n_albums">%1$d albums</string>
<string name="n_albums_one">%1$d album</string>
<string name="n_tracks">%1$d tracks</string>
<string name="n_tracks_one">%1$d track</string>
<string name="next">Next</string>
<string name="no_albums">No albums</string>
<string name="no_albums_library">No albums in this library</string>
<string name="no_artists_library">No artists in this library</string>
<string name="no_lyrics">No lyrics for this track</string>
<string name="no_music_libraries">%1$s has no music libraries</string>
<string name="no_playlists">No music playlists yet</string>
<string name="no_sd">No SD card available</string>
@@ -117,12 +122,14 @@
<string name="play">Play</string>
<string name="play_all">Play all</string>
<string name="play_next">Play next</string>
<string name="play_pause">Play or pause</string>
<string name="playback_error">Playback error (%1$d/%2$d)</string>
<string name="playing_from">PLAYING FROM</string>
<string name="playing_next">Playing next</string>
<string name="playing_x">Playing %1$s</string>
<string name="playlist">Playlist</string>
<string name="plex_server">Plex server</string>
<string name="previous">Previous</string>
<string name="quality_away_title">Quality away / mobile</string>
<string name="quality_bt_title">Quality over Bluetooth</string>
<string name="quality_home_title">Quality at home</string>
@@ -237,5 +244,6 @@
<string name="unlove">Remove from favourites</string>
<string name="unloved">Removed from favourites</string>
<string name="up_next">Queue</string>
<string name="widget_name">Plexamp Lite player</string>
<string name="you">you</string>
</resources>
+2
View File
@@ -19,6 +19,8 @@
<dimen name="text_mini_sub">13sp</dimen>
<dimen name="text_card_title">13sp</dimen>
<dimen name="text_log">11sp</dimen>
<dimen name="text_lyrics">19sp</dimen> <!-- timed lyrics in Now Playing -->
<dimen name="text_lyrics_plain">16sp</dimen> <!-- plain-text lyrics -->
<!-- touch targets and bars -->
<dimen name="touch">48dp</dimen>
+8
View File
@@ -72,21 +72,26 @@
<string name="loading_albums">Cargando álbums…</string>
<string name="loading_artists">Cargando artistas…</string>
<string name="loading_libraries">Cargando bibliotecas…</string>
<string name="loading_lyrics">Cargando a letra…</string>
<string name="loading_playlists">Cargando listas…</string>
<string name="log_empty">Aínda non hai nada no rexistro.</string>
<string name="love">Engadir a favoritas</string>
<string name="loved">Engadida a favoritas</string>
<string name="loved_radio">Radio de favoritas</string>
<string name="loved_radio_sub">As mellor valoradas</string>
<string name="lyrics">Letra</string>
<string name="lyrics_failed">Non se puido cargar a letra</string>
<string name="min">%1$d min</string>
<string name="music">Música</string>
<string name="n_albums">%1$d álbums</string>
<string name="n_albums_one">%1$d álbum</string>
<string name="n_tracks">%1$d pistas</string>
<string name="n_tracks_one">%1$d pista</string>
<string name="next">Seguinte</string>
<string name="no_albums">Non hai álbums</string>
<string name="no_albums_library">Esta biblioteca non ten álbums</string>
<string name="no_artists_library">Esta biblioteca non ten artistas</string>
<string name="no_lyrics">Esta pista non ten letra</string>
<string name="no_music_libraries">%1$s non ten bibliotecas de música</string>
<string name="no_playlists">Aínda non hai listas de música</string>
<string name="no_sd">Non hai tarxeta SD dispoñible</string>
@@ -117,12 +122,14 @@
<string name="play">Reproducir</string>
<string name="play_all">Reproducir</string>
<string name="play_next">Reproducir a continuación</string>
<string name="play_pause">Reproducir ou pausar</string>
<string name="playback_error">Erro de reprodución (%1$d/%2$d)</string>
<string name="playing_from">REPRODUCINDO DESDE</string>
<string name="playing_next">Soará a continuación</string>
<string name="playing_x">Reproducindo %1$s</string>
<string name="playlist">Lista de reprodución</string>
<string name="plex_server">Servidor Plex</string>
<string name="previous">Anterior</string>
<string name="quality_away_title">Calidade fóra / datos móbiles</string>
<string name="quality_bt_title">Calidade por Bluetooth</string>
<string name="quality_home_title">Calidade na casa</string>
@@ -237,5 +244,6 @@
<string name="unlove">Quitar de favoritas</string>
<string name="unloved">Quitada de favoritas</string>
<string name="up_next">Cola</string>
<string name="widget_name">Reprodutor de Plexamp Lite</string>
<string name="you">ti</string>
</resources>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.NoTitleBar">
<style name="AppTheme" parent="@style/BaseTheme">
<item name="android:windowBackground">@color/bg</item>
<item name="android:textColor">@color/text</item>
<item name="android:textColorPrimary">@color/text</item>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Android look the app's theme (styles.xml) builds on. The Android 4.1 build replaces this file
(platform/api16/res/values) with Holo, for Android 4's dialogs, spinners and text fields. -->
<resources>
<style name="BaseTheme" parent="@android:style/Theme.NoTitleBar" />
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 4x1 on both Android 2.x (74n-2 dp cells) and 4.x (70n-30 dp). The Android 4.1 build replaces this file
(platform/api16/res/xml) to make the widget resizable. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="40dp"
android:updatePeriodMillis="0"
android:initialLayout="@layout/widget_player" />
+22 -8
View File
@@ -11,13 +11,14 @@ over a Bluetooth speaker.
## Target and hard limits
- Samsung Galaxy Mini GT-S5570: Android 2.2/2.3 (API 8), 240×320 ldpi, 600 MHz ARMv6, 384 MB RAM; any 2.2+
phone.
- Native Java (this app uses XML layouts), no libraries beyond the bundled Bouncy Castle TLS.
phone. A second build for Android 4.1+ (API 16: Xperia J, Galaxy S II), one per Android level, never per phone.
- Native Java (this app uses XML layouts), no libraries beyond the bundled Bouncy Castle TLS; the Android 4.1 build
adds ExoPlayer 2.18.7 (+ Guava), shrunk by R8.
- Galician by default, English in Settings. All text in `tools/strings.py`, which generates
`res/values*/strings.xml`. The connection log stays in English.
- Plays MP3/AAC/Vorbis directly; FLAC, ALAC and the rest are transcoded to MP3 by the server.
## Current scope (1.13)
## Current scope (1.14)
- Sign in with a plex.tv/link PIN (or a manual address + token). Owned and shared servers and libraries.
- Connection picking: last good one first, then LAN HTTP, LAN HTTPS (`plex.direct`), remote HTTPS, Relay
@@ -27,11 +28,17 @@ over a Bluetooth speaker.
recovery (re-pick, then download first), resume after short Bluetooth drop-outs.
- Home radios and shelves, Artists/Albums/Playlists, Search, artist/album/playlist pages, Now Playing, Up
Next queue (survives restarts), sleep timer, love, downloads to SD card with offline play reporting.
- Headset and Bluetooth buttons; pauses for calls and unplugging. Plays reported to Plex.
- Headset and Bluetooth buttons; pauses for calls and unplugging. Plays reported to Plex (not while
`PlexampLite-noreport` is on the SD card: for tests against a real server).
- Lyrics in Now Playing (as in the Mac app): a lasting on/off; timed lyrics follow and seek; plain text scrolls.
- Home-screen widget (both builds).
- Android 4.1 build: ExoPlayer with gapless hand-over to read-ahead/downloaded files, notification and lock screen
with art and buttons, resizable widget, Holo, ⋮ without a Menu key, metered networks.
## Out of scope
- Gapless playback and notification playback buttons (Android 2.x can't), video, photos.
- On the Android 2.x build: gapless playback and notification playback buttons (Android 2.x can't).
- Video, photos.
## Must not break
@@ -41,17 +48,24 @@ over a Bluetooth speaker.
- This repo's `tools/setup_toolchain.sh`, `make_roots.py` and `signapk.py` prepare the toolchain all the
Galaxy Mini apps use: changes there affect every app.
- Installs to internal storage (SD-card installs get killed during USB sharing).
- `platform/api8` and `platform/api16` keep the same `Platform` methods; shared code calls no API the API 8 jar
lacks (the api8 compile and Dalvik check catch it). Each build's `versionCode` is the manifest's × 100 + level,
same key; both APKs are listed in `../tenda-lite/repo/apps.json`.
- The Android 4.1 build targets API 16: no `clipPath` in drawing (the GPU can't before 4.3), and network on the
main thread is allowed in `Platform.init` (the shared code closes connections there).
## Code map
`PlayerService` (playback, recovery) · `PlayQueue` · `Downloads` · `plex/` (`Plex`, `Server`, `Library`,
`PlayerService` (playback, recovery, gapless) · `Engine` (`MediaPlayerEngine`; `ExoEngine` in platform/api16) ·
`Platform` (per level) · `PlayerWidget` + `PlayerViews` · `Lyrics` + `LyricsView` + `util/Lrc` · · `PlayQueue` · `Downloads` · `plex/` (`Plex`, `Server`, `Library`,
`Item`) · `net/` (`Http`, `CertVerifier`, `StreamProxy`, `Threads`, `DiagLog`) · activities Home, Browse,
NowPlaying, Queue, Search, Setup, Downloads.
## Build, test, release
- `tools/setup_toolchain.sh` once, then `./build.sh` → `build/PlexampLite.apk`.
- Tests on the desktop JVM: `PlexTest` with `test/mock_plex.py`, `QueueTest`, `PreferHomeTest` with
- `tools/setup_toolchain.sh` and `tools/fetch_exoplayer.sh` once, then `./build.sh` → `build/PlexampLite.apk`
and `build/PlexampLite-api16.apk`.
- Tests on the desktop JVM: `PlexTest` with `test/mock_plex.py`, `QueueTest`, `LyricsTest`, `PreferHomeTest` with
`test/identity_server.py`, `NetTest`, `HandshakeProfile`.
- Release: raise `versionCode`/`versionName`, build, add `changes` in `../tenda-lite/repo/apps.json`, commit
and push, `../tenda-lite/repo/publish.sh`.
+8 -8
View File
@@ -16,7 +16,6 @@ import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Build;
import android.os.Handler;
@@ -46,6 +45,7 @@ public class App extends Application {
@Override
public void onCreate() {
super.onCreate();
Platform.init(this);
instance = this;
handler = new Handler();
prefs = getSharedPreferences("minamp", MODE_PRIVATE);
@@ -73,6 +73,10 @@ public class App extends Application {
try { Plex.version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; } catch (Exception ignored) {}
CertVerifier.setRootStore(new AssetRoots(this));
try {
Library.noReport = new java.io.File(android.os.Environment.getExternalStorageDirectory(), "PlexampLite-noreport").exists();
} catch (Exception ignored) {}
if (Library.noReport) com.minamp.net.DiagLog.log("PlexampLite-noreport on the SD card: plays aren't reported to Plex");
Library.preferredVariant = Math.max(0, Math.min(Library.VARIANTS - 1, prefs.getInt("tcVariant", 0)));
images = new ImageLoader(this);
Server.preferSecureAtHome = prefs.getBoolean("secureHome", false);
@@ -251,6 +255,7 @@ public class App extends Application {
/** Sends plays kept while offline (called when a connection has just been found). */
void flushPendingScrobbles() {
if (Library.noReport) return; // keep them for a normal run
final String cur;
synchronized (this) {
cur = prefs.getString("pendingScrobbles", "");
@@ -284,14 +289,9 @@ public class App extends Application {
// ------------------------------------------------------------------ quality
/** On mobile data (Android 4.1+: any network that costs, a phone's hotspot too): the "away" quality applies. */
public boolean onMobileData() {
try {
ConnectivityManager cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
NetworkInfo ni = cm.getActiveNetworkInfo();
return ni != null && ni.getType() != ConnectivityManager.TYPE_WIFI;
} catch (Exception e) {
return false;
}
return Platform.metered(this);
}
public int qualityHome() { return prefs.getInt("qHome", 0); }
+15
View File
@@ -99,6 +99,21 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
.show();
}
@Override
protected void onPostCreate(android.os.Bundle b) {
super.onPostCreate(b);
// phones without a Menu key (some Android 4 ones): a ⋮ in the top bar opens the options menu
ImageButton more = (ImageButton) findViewById(R.id.btn_action2);
if (hasOptionsMenu() && more != null && more.getVisibility() != View.VISIBLE && Platform.needsMenuButton(this)) {
barAction(R.id.btn_action2, Glyph.MORE, new View.OnClickListener() {
public void onClick(View v) { openOptionsMenu(); }
});
}
}
/** This screen has the options menu (Now playing, Search, Settings...). */
protected boolean hasOptionsMenu() { return true; }
@Override
protected void onResume() {
super.onResume();
+54
View File
@@ -0,0 +1,54 @@
package com.minamp;
import java.io.File;
/**
* The audio player behind {@link PlayerService}, one per track (or per run of gapless tracks). Android 2.x builds use
* {@link MediaPlayerEngine}; the Android 4.1 build uses ExoPlayer ({@code platform/api16/.../ExoEngine}), see
* {@link Platform#newEngine}. Called on the main thread only, and calls its listener there.
*/
interface Engine {
interface Listener {
/** Ready to play (once per {@link #prepare}). */
void onPrepared(Engine e);
/** The last item played to its end. */
void onCompletion(Engine e);
/** Playback failed. {@code decoder}: this engine can't decode the audio; another engine might. */
void onError(Engine e, String message, boolean decoder);
/** The item queued with {@link #setNext} took over without a gap. */
void onGaplessNext(Engine e);
}
void setListener(Listener l);
/** The audio: a file, or an http:// address (the loopback {@code StreamProxy}). One of the two. */
void setSource(File file, String url) throws Exception;
void prepare();
void start();
void pause();
void seekTo(long ms);
/** Position in the current item, in ms. */
long position();
void setVolume(float v);
/** Seeking works in what's playing (false: a stream with no known length, which must be restarted instead). */
boolean canSeek();
/** Can play a file straight after the current item without a gap. */
boolean supportsNext();
/** The file to play after the current item (null: none). Only when {@link #supportsNext}. */
void setNext(File file);
void release();
/** For the connection log: "MediaPlayer", "ExoPlayer". */
String name();
}
+36 -7
View File
@@ -1,5 +1,6 @@
package com.minamp;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
@@ -13,7 +14,7 @@ import android.graphics.drawable.Drawable;
public final class Glyph extends Drawable {
public static final int PLAY = 1, PAUSE = 2, NEXT = 3, PREV = 4, SHUFFLE = 5, REPEAT = 6, REPEAT_ONE = 7,
HEART = 8, HEART_OUTLINE = 9, QUEUE = 10, SEARCH = 11, MOON = 12, RADIO = 13, BACK = 14, MORE = 15, NOTE = 16, CLOCK = 17;
HEART = 8, HEART_OUTLINE = 9, QUEUE = 10, SEARCH = 11, MOON = 12, RADIO = 13, BACK = 14, MORE = 15, NOTE = 16, CLOCK = 17, CLOSE = 18, LYRICS = 19;
private int type;
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -147,13 +148,19 @@ public final class Glyph extends Drawable {
break;
}
case MOON: {
Path p2 = new Path();
path.addCircle(cx, cy, s * 0.45f, Path.Direction.CW);
c.save();
p2.addCircle(cx + s * 0.25f, cy - s * 0.2f, s * 0.38f, Path.Direction.CW);
c.clipPath(p2, android.graphics.Region.Op.DIFFERENCE);
// a crescent: this circle minus an offset one, traced as two arcs (Android 4.x draws with the GPU,
// which can't clipPath before Android 4.3)
float rBig = s * 0.45f, rCut = s * 0.38f, dx = s * 0.25f, dy = -s * 0.2f;
float d = (float) Math.sqrt(dx * dx + dy * dy);
float a = (d * d + rBig * rBig - rCut * rCut) / (2 * d); // centre -> the arcs' chord, along dx,dy
double th = Math.toDegrees(Math.atan2(dy, dx));
double al = Math.toDegrees(Math.acos(a / rBig));
double ga = Math.toDegrees(Math.acos((a - d) / rCut));
path.arcTo(new RectF(cx - rBig, cy - rBig, cx + rBig, cy + rBig), (float) (th + al), (float) (360 - 2 * al), true);
path.arcTo(new RectF(cx + dx - rCut, cy + dy - rCut, cx + dx + rCut, cy + dy + rCut),
(float) (th - ga), (float) -(360 - 2 * ga), false);
path.close();
c.drawPath(path, fill);
c.restore();
break;
}
case RADIO: {
@@ -183,6 +190,18 @@ public final class Glyph extends Drawable {
c.drawLine(l + rad * 2 + s * 0.1f, t, r - s * 0.1f, t + s * 0.2f, stroke);
break;
}
case LYRICS: { // a microphone
float w = s * 0.34f;
c.drawRoundRect(new RectF(cx - w / 2, t, cx + w / 2, t + s * 0.58f), w / 2, w / 2, fill);
c.drawArc(new RectF(cx - s * 0.33f, t + s * 0.12f, cx + s * 0.33f, t + s * 0.72f), 15, 150, false, stroke);
c.drawLine(cx, t + s * 0.72f, cx, bt, stroke);
c.drawLine(cx - s * 0.2f, bt, cx + s * 0.2f, bt, stroke);
break;
}
case CLOSE:
c.drawLine(l + s * 0.15f, t + s * 0.15f, r - s * 0.15f, bt - s * 0.15f, stroke);
c.drawLine(r - s * 0.15f, t + s * 0.15f, l + s * 0.15f, bt - s * 0.15f, stroke);
break;
case CLOCK:
c.drawCircle(cx, cy, s * 0.45f, stroke);
c.drawLine(cx, cy, cx, t + s * 0.18f, stroke);
@@ -191,6 +210,16 @@ public final class Glyph extends Drawable {
}
}
/** The glyph drawn into a bitmap, for views in other processes (widget, notification). */
public static Bitmap bitmap(int type, int color, int px) {
Bitmap bm = Bitmap.createBitmap(px, px, Bitmap.Config.ARGB_8888);
Glyph g = new Glyph(type, color, px);
g.scale(1f);
g.setBounds(0, 0, px, px);
g.draw(new Canvas(bm));
return bm;
}
private void arrowHead(Canvas c, float tipX, float y, float a) {
Path p = new Path();
p.moveTo(tipX, y);
+116
View File
@@ -0,0 +1,116 @@
package com.minamp;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.minamp.plex.Item;
import com.minamp.plex.Library;
import com.minamp.util.Lrc;
/**
* The playing track's lyrics from Plex (as in the Mac app). Showing them is a lasting setting, not a per-track
* choice: while it's on, each new track's lyrics are fetched. Main thread.
*/
final class Lyrics {
static final int NONE = 0, LOADING = 1, READY = 2, MISSING = 3, FAILED = 4;
interface Listener { void onLyricsChanged(); }
private static final Lyrics INSTANCE = new Lyrics();
static Lyrics get() { return INSTANCE; }
private final ArrayList<Listener> listeners = new ArrayList<Listener>();
private final Map<String, Object[]> cache = new LinkedHashMap<String, Object[]>(16, 0.75f, true) {
protected boolean removeEldestEntry(Map.Entry<String, Object[]> e) { return size() > 30; }
};
private int state = NONE;
private List<Lrc.Line> lines = Collections.emptyList();
private boolean synced;
private String ratingKey;
private int gen;
void addListener(Listener l) { if (!listeners.contains(l)) listeners.add(l); }
void removeListener(Listener l) { listeners.remove(l); }
int state() { return state; }
List<Lrc.Line> lines() { return lines; }
boolean synced() { return synced; }
/** Index of the line being sung at ms (-1 before the first line, or when they aren't timed). */
int lineAt(long ms) { return synced ? Lrc.lineAt(lines, ms) : -1; }
boolean enabled() { return App.get().prefs.getBoolean("lyrics", false); }
void setEnabled(boolean on) {
App.get().prefs.edit().putBoolean("lyrics", on).commit();
if (on) loadFor(PlayQueue.get().current());
changed();
}
/** Fetches this track's lyrics if they're on and not already here. */
void loadFor(Item track) {
if (track == null || track.ratingKey.length() == 0) {
gen++;
ratingKey = null;
set(NONE, Collections.<Lrc.Line>emptyList(), false);
return;
}
if (!enabled() || (track.ratingKey.equals(ratingKey) && state != FAILED)) return;
final int g = ++gen;
final Item t = track;
ratingKey = t.ratingKey;
Object[] hit = cache.get(t.ratingKey);
if (hit != null) {
@SuppressWarnings("unchecked") List<Lrc.Line> l = (List<Lrc.Line>) hit[0];
set(l.isEmpty() ? MISSING : READY, l, (Boolean) hit[1]);
return;
}
set(LOADING, Collections.<Lrc.Line>emptyList(), false);
final Library lib = App.get().library;
com.minamp.net.Threads.start(new Runnable() {
public void run() {
List<Lrc.Line> l = null;
boolean[] timed = new boolean[1];
String why = null;
try {
if (lib == null) throw new java.io.IOException("not connected");
l = Lrc.parse(lib.lyrics(t), timed);
com.minamp.net.DiagLog.log("lyrics for \"" + t.title + "\": " + l.size() + " lines" + (timed[0] ? ", timed" : ""));
} catch (Throwable e) {
why = com.minamp.net.Threads.describe(e);
}
final List<Lrc.Line> fl = l;
final boolean fs = timed[0];
final String fwhy = why;
App.get().handler.post(new Runnable() {
public void run() {
if (g != gen) return;
if (fl == null) {
com.minamp.net.DiagLog.log("lyrics for \"" + t.title + "\" failed: " + fwhy);
set(FAILED, Collections.<Lrc.Line>emptyList(), false);
return;
}
cache.put(t.ratingKey, new Object[] {fl, fs});
set(fl.isEmpty() ? MISSING : READY, fl, fs);
}
});
}
}, "lyrics");
}
private void set(int s, List<Lrc.Line> l, boolean timed) {
state = s;
lines = l;
synced = timed;
changed();
}
private void changed() {
for (int i = listeners.size() - 1; i >= 0; i--) if (i < listeners.size()) listeners.get(i).onLyricsChanged();
}
}
+162
View File
@@ -0,0 +1,162 @@
package com.minamp;
import java.util.ArrayList;
import java.util.List;
import android.content.Context;
import android.graphics.Typeface;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import com.minamp.util.Lrc;
/**
* Now Playing's lyrics, in place of the art (as in the Mac app): timed lyrics follow the song with the current line
* lit, a tap on a line jumps there, and scrolling by hand pauses the following for a few seconds; plain-text lyrics
* just scroll.
*/
public class LyricsView extends ScrollView implements Lyrics.Listener {
private static final int FOLLOW_PAUSE_MS = 4000;
private final LinearLayout column;
private final List<TextView> rows = new ArrayList<TextView>();
private List<Lrc.Line> shown;
private int current = -2;
private long touchedAt;
private boolean running;
public LyricsView(Context c, AttributeSet a) {
super(c, a);
setVerticalFadingEdgeEnabled(true);
setFadingEdgeLength(Ui.dp(c, 24));
setVerticalScrollBarEnabled(false);
column = new LinearLayout(c);
column.setOrientation(LinearLayout.VERTICAL);
int p = Ui.dp(c, 4);
column.setPadding(p, 0, p, 0);
addView(column, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
Lyrics.get().addListener(this);
onLyricsChanged();
updateRunning();
}
@Override
protected void onDetachedFromWindow() {
Lyrics.get().removeListener(this);
running = false;
removeCallbacks(tick);
super.onDetachedFromWindow();
}
@Override
protected void onWindowVisibilityChanged(int v) {
super.onWindowVisibilityChanged(v);
updateRunning();
}
@Override
public void setVisibility(int v) {
super.setVisibility(v);
updateRunning();
}
private void updateRunning() {
boolean want = getVisibility() == VISIBLE && getWindowVisibility() == VISIBLE;
if (want == running) return;
running = want;
removeCallbacks(tick);
if (want) post(tick);
}
private final Runnable tick = new Runnable() {
public void run() {
if (!running) return;
follow(false);
postDelayed(this, 250);
}
};
@Override
public boolean onTouchEvent(MotionEvent e) {
if (e.getAction() == MotionEvent.ACTION_MOVE) touchedAt = System.currentTimeMillis();
return super.onTouchEvent(e);
}
public void onLyricsChanged() {
Lyrics ly = Lyrics.get();
List<Lrc.Line> lines = ly.lines();
if (lines == shown && !rows.isEmpty()) return;
shown = lines;
column.removeAllViews();
rows.clear();
current = -2;
scrollTo(0, 0);
Context c = getContext();
int st = ly.state();
if (st != Lyrics.READY) {
int msg = st == Lyrics.LOADING ? R.string.loading_lyrics : st == Lyrics.FAILED ? R.string.lyrics_failed : R.string.no_lyrics;
TextView t = new TextView(c);
t.setText(msg);
t.setTextColor(0x99FFFFFF);
t.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(R.dimen.text_np_sub));
t.setGravity(Gravity.CENTER);
t.setPadding(0, Ui.dp(c, 40), 0, 0);
column.addView(t, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
return;
}
boolean timed = ly.synced();
float size = getResources().getDimension(timed ? R.dimen.text_lyrics : R.dimen.text_lyrics_plain);
int gap = Ui.dp(c, timed ? 10 : 4);
// a blank first screen-third, so the first line can sit where the following ones will
column.setPadding(column.getPaddingLeft(), timed ? Ui.dp(c, 8) : 0, column.getPaddingRight(), Ui.dp(c, 48));
for (int i = 0; i < lines.size(); i++) {
final Lrc.Line l = lines.get(i);
TextView t = new TextView(c);
t.setText(l.text);
t.setTextSize(TypedValue.COMPLEX_UNIT_PX, size);
t.setTypeface(Typeface.DEFAULT_BOLD);
t.setTextColor(timed ? 0x66FFFFFF : 0xE6FFFFFF);
t.setPadding(0, gap / 2, 0, gap / 2);
if (l.text.length() == 0) t.setHeight(Ui.dp(c, 10)); // a pause between verses
if (timed && l.text.length() > 0) {
t.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
PlayerService.seekTo(getContext(), l.time);
touchedAt = 0;
}
});
}
rows.add(t);
column.addView(t, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
}
if (timed) post(new Runnable() { public void run() { follow(true); } });
}
/** Lights the line being sung and, unless the lyrics were just scrolled by hand, brings it a third of the way down. */
private void follow(boolean jump) {
Lyrics ly = Lyrics.get();
if (!ly.synced() || rows.isEmpty()) return;
int now = ly.lineAt(PlayerService.position());
if (now != current) {
for (int i = 0; i < rows.size(); i++) rows.get(i).setTextColor(i == now ? 0xFFFFFFFF : i < now ? 0x73FFFFFF : 0x66FFFFFF);
boolean moved = current != -2;
current = now;
if (System.currentTimeMillis() - touchedAt < FOLLOW_PAUSE_MS) return;
View row = rows.get(Math.max(0, now));
int y = Math.max(0, row.getTop() + column.getTop() - getHeight() / 3);
if (moved && !jump) smoothScrollTo(0, y); else scrollTo(0, y);
}
}
}
+4
View File
@@ -47,8 +47,12 @@ public class MediaButtonReceiver extends BroadcastReceiver {
PlayerService.send(app, PlayerService.ACTION_PREV);
break;
case KeyEvent.KEYCODE_MEDIA_STOP:
case 127: // KEYCODE_MEDIA_PAUSE (Android 3+: the lock screen, newer headsets)
PlayerService.send(app, PlayerService.ACTION_PAUSE);
break;
case 126: // KEYCODE_MEDIA_PLAY
PlayerService.send(app, PlayerService.ACTION_PLAY);
break;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
package com.minamp;
import java.io.File;
import java.io.FileInputStream;
import android.content.Context;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.os.PowerManager;
/** Android's own MediaPlayer: every Android version. No gapless (Android 2.x can't hand over to a next player). */
final class MediaPlayerEngine implements Engine, MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener,
MediaPlayer.OnErrorListener {
private final MediaPlayer mp = new MediaPlayer();
private Listener listener;
MediaPlayerEngine(Context c) {
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
mp.setWakeMode(c, PowerManager.PARTIAL_WAKE_LOCK);
mp.setOnPreparedListener(this);
mp.setOnCompletionListener(this);
mp.setOnErrorListener(this);
}
public void setListener(Listener l) { listener = l; }
public void setSource(File file, String url) throws Exception {
if (file != null) {
FileInputStream fis = new FileInputStream(file);
try { mp.setDataSource(fis.getFD()); } finally { fis.close(); }
} else {
mp.setDataSource(url);
}
}
public void prepare() { mp.prepareAsync(); }
public void start() { mp.start(); }
public void pause() { mp.pause(); }
public void seekTo(long ms) { mp.seekTo((int) ms); }
public long position() { return mp.getCurrentPosition(); }
public void setVolume(float v) { mp.setVolume(v, v); }
public boolean canSeek() { return true; }
public boolean supportsNext() { return false; }
public void setNext(File file) {}
public void release() {
listener = null;
try { mp.reset(); } catch (Exception ignored) {}
try { mp.release(); } catch (Exception ignored) {}
}
public String name() { return "MediaPlayer"; }
public void onPrepared(MediaPlayer p) { if (listener != null) listener.onPrepared(this); }
public void onCompletion(MediaPlayer p) { if (listener != null) listener.onCompletion(this); }
public boolean onError(MediaPlayer p, int what, int extra) {
if (listener != null) listener.onError(this, App.get().getString(R.string.playback_error, what, extra), false);
return true;
}
}
+25 -1
View File
@@ -20,7 +20,8 @@ public class NowPlayingActivity extends BaseActivity {
private TextView title, sub, from, elapsed, remaining, status, sleepText, albumLink;
private SeekBar seek;
private ImageButton playBtn, shuffleBtn, repeatBtn, loveBtn;
private Glyph playGlyph, shuffleGlyph, repeatGlyph, loveGlyph;
private Glyph playGlyph, shuffleGlyph, repeatGlyph, loveGlyph, lyricsGlyph;
private LyricsView lyrics;
private View root;
private boolean dragging;
private String shownThumb;
@@ -59,6 +60,19 @@ public class NowPlayingActivity extends BaseActivity {
public void onClick(View v) { startActivity(new Intent(NowPlayingActivity.this, QueueActivity.class)); }
});
lyrics = (LyricsView) findViewById(R.id.np_lyrics);
ImageButton lyricsBtn = (ImageButton) findViewById(R.id.np_lyrics_btn);
lyricsGlyph = Ui.glyph(this, Glyph.LYRICS, white, 24);
lyricsGlyph.scale(1f);
lyricsBtn.setImageDrawable(lyricsGlyph);
lyricsBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
// a lasting setting: stays on for the following tracks and after restarts
Lyrics.get().setEnabled(!Lyrics.get().enabled());
showLyrics();
}
});
playGlyph = Ui.glyph(this, Glyph.PLAY, getResources().getColor(R.color.on_accent), 28);
playGlyph.scale(1f);
playBtn.setImageDrawable(playGlyph);
@@ -193,10 +207,20 @@ public class NowPlayingActivity extends BaseActivity {
}
});
}
Lyrics.get().loadFor(it);
showLyrics();
updateSleep();
updateTime();
}
/** Lyrics on: they take the art's place, and the button is lit. */
private void showLyrics() {
boolean on = Lyrics.get().enabled();
lyrics.setVisibility(on ? View.VISIBLE : View.GONE);
art.setVisibility(on ? View.INVISIBLE : View.VISIBLE);
lyricsGlyph.setColor(on ? accent : white);
}
private void updateTime() {
long d = PlayerService.duration();
long p = PlayerService.position();
+148 -51
View File
@@ -1,7 +1,6 @@
package com.minamp;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -10,19 +9,17 @@ import java.util.concurrent.ExecutorService;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Bitmap;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.net.wifi.WifiManager;
import android.os.Environment;
import android.os.IBinder;
import android.os.PowerManager;
import android.telephony.PhoneStateListener;
import android.telephony.TelephonyManager;
import android.widget.Toast;
@@ -34,12 +31,12 @@ import com.minamp.plex.Library;
import com.minamp.plex.Plex;
/**
* Foreground playback service: one MediaPlayer per track, fed through the loopback
* {@link StreamProxy}. Handles audio focus, calls, headset buttons/unplug, Plex timeline
* reporting, retries after network changes, and the sleep timer.
* Foreground playback service: one {@link Engine} per track (Android's MediaPlayer, or ExoPlayer on the Android 4.1
* build), fed through the loopback {@link StreamProxy} or from a file. Handles audio focus, calls, headset
* buttons/unplug, Plex timeline reporting, retries after network changes, the sleep timer, gapless hand-over to a
* read-ahead track (ExoPlayer), and keeps the notification, the lock screen and the home-screen widget up to date.
*/
public class PlayerService extends Service implements MediaPlayer.OnPreparedListener,
MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, AudioManager.OnAudioFocusChangeListener {
public class PlayerService extends Service implements Engine.Listener, AudioManager.OnAudioFocusChangeListener {
public static final String ACTION_PLAY_CURRENT = "com.minamp.PLAY_CURRENT";
public static final String ACTION_TOGGLE = "com.minamp.TOGGLE";
@@ -112,11 +109,19 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private final ExecutorService worker = com.minamp.net.Threads.pool("player", 1);
private final ExecutorService reporter = com.minamp.net.Threads.pool("report", 1);
private StreamProxy proxy;
private MediaPlayer mp;
private Engine mp;
private boolean prepared;
private boolean fallbackEngine; // this track failed to decode in ExoPlayer: use Android's MediaPlayer
// gapless: the track the engine plays after this one (ExoPlayer only), main thread
private Item nextItem;
private File nextFile;
private Ahead nextAhead;
// the current track's art, for the notification, lock screen and widget
private String artThumb;
private Bitmap art;
private int gen;
private Item track;
private long offsetMs; // transcodes restart at an offset; MediaPlayer counts from 0
private long offsetMs; // transcodes restart at an offset; the engine counts from 0
private long pendingPos; // position to show / seek to before prepared
private boolean playWhenReady;
private boolean transcoding;
@@ -134,7 +139,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private boolean scrobbled;
private long lastTimeline;
private long lastSave;
private long lastPos; // last position seen while playing (getCurrentPosition is unreliable after completion)
private long lastPos; // last position seen while playing (the engine's is unreliable after completion)
private boolean localFile; // playing a download from the SD card
private long stallPos = -1; // position last seen moving
private long stallSince;
@@ -221,6 +226,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
abandonFocus();
unregisterNoisy();
if (wifiLock.isHeld()) wifiLock.release();
Platform.lockScreen(this, null, null, IDLE);
audio.unregisterMediaButtonEventReceiver(new ComponentName(this, MediaButtonReceiver.class));
worker.shutdownNow();
reporter.shutdown();
@@ -273,6 +279,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private void newTrack() {
retries = 0;
forceDownload = false;
fallbackEngine = false;
}
// ------------------------------------------------------------------ playback pipeline
@@ -551,20 +558,14 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private void begin(java.util.List<String> urls, String ext, File file, long offset, long seekTo) {
try {
mp = new MediaPlayer();
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
mp.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK);
mp.setOnPreparedListener(this);
mp.setOnCompletionListener(this);
mp.setOnErrorListener(this);
mp = Platform.newEngine(this, fallbackEngine);
mp.setListener(this);
prepared = false;
fromFile = file != null;
offsetMs = offset;
pendingPos = seekTo;
if (fromFile) {
FileInputStream fis = new FileInputStream(file);
mp.setDataSource(fis.getFD());
fis.close();
mp.setSource(file, null);
} else {
if (proxy.port() < 0) proxy.start();
routeId = proxy.newId();
@@ -572,22 +573,22 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
refusalCode = 0;
acceptedVariant = -1;
// transcodes are fetched once and buffered, so player reconnects can't restart them
mp.setDataSource(proxy.register(routeId, urls, ext, transcoding,
mp.setSource(null, proxy.register(routeId, urls, ext, transcoding,
App.get().server != null ? App.get().server.mediaHeaders() : null));
}
setState(BUFFERING, getString(R.string.buffering));
mp.prepareAsync();
mp.prepare();
} catch (Exception e) {
trackFailed(message(e));
}
}
public void onPrepared(MediaPlayer p) {
if (p != mp) return;
public void onPrepared(Engine e) {
if (e != mp) return;
prepared = true;
failuresInRow = 0;
if (pendingPos > 0) {
try { mp.seekTo((int) pendingPos); } catch (Exception ignored) {}
try { mp.seekTo(pendingPos); } catch (Exception ignored) {}
}
pendingPos = 0;
if (playWhenReady && (hasFocus || requestFocus())) {
@@ -602,8 +603,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
report(true);
}
public void onCompletion(MediaPlayer p) {
if (p != mp) return;
public void onCompletion(Engine e) {
if (e != mp) return;
long pos = lastPos;
Item it = track;
// A stream that ends well before the track does means the network dropped.
@@ -630,10 +631,87 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
}
}
public boolean onError(MediaPlayer p, int what, int extra) {
if (p != mp) return true;
trackFailed(getString(R.string.playback_error, what, extra));
return true;
public void onError(Engine e, String message, boolean decoder) {
if (e != mp) return;
if (decoder && !fallbackEngine && !(e instanceof MediaPlayerEngine)) {
// ExoPlayer can't decode this file on this phone: Android's own player may (it has other decoders)
com.minamp.net.DiagLog.log(e.name() + " can't decode \"" + (track != null ? track.title : "") + "\" ("
+ message + "): trying Android's MediaPlayer");
fallbackEngine = true;
startCurrent(Math.max(currentPosition(), lastPos), true);
return;
}
trackFailed(message);
}
/** Hands the engine the next track's file, when it's already here, so it starts without a gap. Main thread. */
private void maybeQueueNext() {
if (mp == null || !prepared || !mp.supportsNext()) return;
PlayQueue q = PlayQueue.get();
Item next = sleepEndOfTrack || q.repeat() == PlayQueue.REPEAT_ONE ? null : q.peekNext();
if (next != null && track != null && next.ratingKey.equals(track.ratingKey)) next = null;
File f = null;
Ahead a = null;
if (next != null) {
f = Downloads.get().fileFor(next);
if (f == null) {
a = ahead.get(next.ratingKey);
if (a != null && a.file.exists()) f = a.file; else a = null;
}
}
if (f == null) next = null;
if (next == nextItem && (f == null ? nextFile == null : f.equals(nextFile))) return;
nextItem = next;
nextFile = f;
nextAhead = a;
mp.setNext(f);
if (next != null) com.minamp.net.DiagLog.log("gapless: \"" + next.title + "\" queued after this track");
}
/** The engine went straight on to the queued file: the same steps as a track ending and the next starting,
* without stopping the engine. */
public void onGaplessNext(Engine e) {
if (e != mp) return;
Item done = track, next = nextItem;
File f = nextFile;
Ahead a = nextAhead;
nextItem = null;
nextFile = null;
nextAhead = null;
if (done != null) {
if (!scrobbled) scrobble(done);
reportState(done, "stopped", done.duration);
}
PlayQueue q = PlayQueue.get();
if (next == null || !q.advance(false) || q.current() == null || !next.ratingKey.equals(q.current().ratingKey)) {
newTrack();
startCurrent(0, true); // the queue changed under us: play what it says now
return;
}
endStream(); // the previous track's stream and transcode, if it had them
if (playingPrefetch != null && !playingPrefetch.equals(f)) playingPrefetch.delete();
ahead.remove(next.ratingKey);
playingPrefetch = a != null ? f : null;
playingAhead = a;
playingAheadKey = a != null ? next.ratingKey : null;
newTrack();
track = next;
scrobbled = false;
offsetMs = 0;
pendingPos = 0;
lastPos = 0;
stallPos = -1;
failedAtPos = -1;
fromFile = true;
localFile = a == null;
transcoding = false;
session = null;
variantOrder = null;
kbps = a != null ? a.kbps : 0;
com.minamp.net.DiagLog.log("gapless: now playing \"" + next.title + "\"");
setState(PLAYING, "");
goForeground();
report(true);
}
private void trackFailed(String msg) {
@@ -708,8 +786,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (track == null) return;
if (ms < 0) ms = 0;
if (track.duration > 0 && ms > track.duration - 1000) ms = Math.max(0, track.duration - 1000);
if (mp != null && prepared && (!transcoding || fromFile || proxy.isComplete(routeId))) {
try { mp.seekTo((int) ms); } catch (Exception ignored) {}
if (mp != null && prepared && mp.canSeek() && (!transcoding || fromFile || proxy.isComplete(routeId))) {
try { mp.seekTo(ms); } catch (Exception ignored) {}
lastPos = ms;
notifyListeners();
} else {
@@ -734,10 +812,25 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
stopForeground(true);
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).cancel(1);
foreground = false;
Platform.lockScreen(this, null, null, IDLE);
PlayerWidget.update(this);
stopSelf();
}
private void releasePlayer() {
endStream();
if (mp != null) {
mp.release();
mp = null;
}
prepared = false;
nextItem = null;
nextFile = null;
nextAhead = null;
}
/** Closes the current track's stream through the proxy and ends its transcode on the server. */
private void endStream() {
if (routeId != null) { proxy.unregister(routeId); routeId = null; }
if (session != null && acceptedVariant >= 0) {
final String s = Library.variantSession(session, acceptedVariant);
@@ -746,17 +839,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
}
session = null;
acceptedVariant = -1;
if (mp != null) {
try { mp.reset(); } catch (Exception ignored) {}
try { mp.release(); } catch (Exception ignored) {}
mp = null;
}
prepared = false;
}
private long currentPosition() {
if (mp != null && prepared) {
try { return offsetMs + mp.getCurrentPosition(); } catch (Exception e) { return offsetMs; }
try { return offsetMs + mp.position(); } catch (Exception e) { return offsetMs; }
}
return pendingPos + offsetMs;
}
@@ -768,6 +855,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (state == PLAYING) {
btOn = audio.isBluetoothA2dpOn();
maybePrefetch();
maybeQueueNext();
}
App.get().handler.postDelayed(this, 1000);
long now = System.currentTimeMillis();
@@ -850,7 +938,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
switch (change) {
case AudioManager.AUDIOFOCUS_GAIN:
hasFocus = true;
if (mp != null) try { mp.setVolume(1f, 1f); } catch (Exception ignored) {}
if (mp != null) try { mp.setVolume(1f); } catch (Exception ignored) {}
if (pausedByFocus) { pausedByFocus = false; play(); }
break;
case AudioManager.AUDIOFOCUS_LOSS:
@@ -863,7 +951,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (state == PLAYING) { pause(); pausedByFocus = true; }
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
if (mp != null) try { mp.setVolume(0.2f, 0.2f); } catch (Exception ignored) {}
if (mp != null) try { mp.setVolume(0.2f); } catch (Exception ignored) {}
break;
}
}
@@ -930,16 +1018,23 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
// ------------------------------------------------------------------ notification / state
@SuppressWarnings("deprecation")
/** The notification, the lock screen and the widget show the current track and state. */
private void goForeground() {
Item it = track != null ? track : PlayQueue.get().current();
final Item it = track != null ? track : PlayQueue.get().current();
if (it == null) return;
Notification n = new Notification(R.drawable.ic_stat, null, 0);
n.flags |= Notification.FLAG_ONGOING_EVENT | Notification.FLAG_NO_CLEAR;
Intent open = new Intent(this, NowPlayingActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pi = PendingIntent.getActivity(this, 0, open, PendingIntent.FLAG_UPDATE_CURRENT);
String line = (state == PAUSED ? getString(R.string.paused_dot) : "") + it.artist;
n.setLatestEventInfo(this, it.title, line, pi);
if (!it.thumb.equals(artThumb)) {
artThumb = it.thumb;
art = null;
final String want = it.thumb;
App.get().images.load(want, Platform.ART_PX, new ImageLoader.Callback() {
public void onBitmap(Bitmap b) {
if (b == null || !want.equals(artThumb) || instance != PlayerService.this) return;
art = b;
if (state != IDLE) goForeground();
}
});
}
Notification n = Platform.playerNotification(this, it, art, state);
if (state == PAUSED || state == IDLE) {
// let Android reclaim us while paused, but keep the notification as a shortcut back
if (foreground) { stopForeground(false); foreground = false; }
@@ -948,6 +1043,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
startForeground(1, n);
foreground = true;
}
Platform.lockScreen(this, it, art, state);
PlayerWidget.update(this);
}
private void setState(int s, String msg) {
+83
View File
@@ -0,0 +1,83 @@
package com.minamp;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.view.View;
import android.widget.RemoteViews;
import com.minamp.plex.Item;
/** The player as RemoteViews: the home-screen widget, and the notification on Android 4.1+. */
final class PlayerViews {
private PlayerViews() {}
private static final int[] BUTTON_GLYPHS = {Glyph.PREV, Glyph.PLAY, Glyph.PAUSE, Glyph.NEXT, Glyph.CLOSE};
private static final Bitmap[] glyphs = new Bitmap[Glyph.CLOSE + 1];
private static Bitmap artSource, artSmall;
/** @param close show the × button that stops playback (the notification) */
static RemoteViews build(Context c, int layout, Item it, Bitmap art, int state, boolean close) {
RemoteViews v = new RemoteViews(c.getPackageName(), layout);
if (it == null) {
v.setTextViewText(R.id.rv_title, c.getString(R.string.nothing_playing));
v.setTextViewText(R.id.rv_sub, c.getString(R.string.app_name));
} else {
v.setTextViewText(R.id.rv_title, it.title);
v.setTextViewText(R.id.rv_sub, (state == PlayerService.PAUSED ? c.getString(R.string.paused_dot) : "") + it.artist);
v.setTextViewText(R.id.rv_album, it.album);
}
Bitmap small = small(c, art);
if (small != null) v.setImageViewBitmap(R.id.rv_art, small);
else v.setImageViewResource(R.id.rv_art, R.drawable.art_placeholder);
boolean playing = state == PlayerService.PLAYING || state == PlayerService.BUFFERING;
v.setImageViewBitmap(R.id.rv_prev, glyph(c, Glyph.PREV));
v.setImageViewBitmap(R.id.rv_toggle, glyph(c, playing ? Glyph.PAUSE : Glyph.PLAY));
v.setImageViewBitmap(R.id.rv_next, glyph(c, Glyph.NEXT));
v.setOnClickPendingIntent(R.id.rv_prev, service(c, PlayerService.ACTION_PREV, 11));
v.setOnClickPendingIntent(R.id.rv_toggle, service(c, PlayerService.ACTION_TOGGLE, 12));
v.setOnClickPendingIntent(R.id.rv_next, service(c, PlayerService.ACTION_NEXT, 13));
v.setOnClickPendingIntent(R.id.rv_art, open(c));
v.setOnClickPendingIntent(R.id.rv_text, open(c));
if (close) {
v.setViewVisibility(R.id.rv_close, View.VISIBLE);
v.setImageViewBitmap(R.id.rv_close, glyph(c, Glyph.CLOSE));
v.setOnClickPendingIntent(R.id.rv_close, service(c, PlayerService.ACTION_STOP, 14));
}
return v;
}
/** Now Playing (or Home when the queue is empty). */
static PendingIntent open(Context c) {
Class<?> screen = PlayQueue.get().current() != null ? NowPlayingActivity.class : HomeActivity.class;
Intent i = new Intent(c, screen).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(c, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
}
static PendingIntent service(Context c, String action, int request) {
return PendingIntent.getService(c, request, new Intent(c, PlayerService.class).setAction(action), 0);
}
private static Bitmap glyph(Context c, int type) {
if (glyphs[type] == null) {
int color = c.getResources().getColor(R.color.text);
for (int i = 0; i < BUTTON_GLYPHS.length; i++) {
int t = BUTTON_GLYPHS[i];
glyphs[t] = Glyph.bitmap(t, color, Ui.dp(c, t == Glyph.CLOSE ? 16 : t == Glyph.PLAY || t == Glyph.PAUSE ? 24 : 18));
}
}
return glyphs[type];
}
/** The art at the size these views show it (a full-size bitmap would be copied to the launcher on every update). */
private static Bitmap small(Context c, Bitmap art) {
if (art == null) return null;
if (art == artSource) return artSmall;
int px = Ui.dp(c, 112);
artSource = art;
artSmall = art.getWidth() <= px ? art : Bitmap.createScaledBitmap(art, px, px * art.getHeight() / art.getWidth(), true);
return artSmall;
}
}
+57
View File
@@ -0,0 +1,57 @@
package com.minamp;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.ComponentName;
import android.content.Context;
import android.graphics.Bitmap;
import android.os.Bundle;
import com.minamp.plex.Item;
/** Home-screen widget: art, title, artist, previous / play-pause / next. Kept up to date by PlayerService. */
public class PlayerWidget extends AppWidgetProvider {
private static String artThumb;
private static Bitmap art;
@Override
public void onUpdate(Context c, AppWidgetManager m, int[] ids) { update(c); }
/** Android 4.1+: the widget was resized (it may now fit the larger layout). */
public void onAppWidgetOptionsChanged(Context c, AppWidgetManager m, int id, Bundle options) { update(c); }
/** Shows the queue's current track and the player's state on every placed widget. Main thread. */
static void update(Context context) {
final Context c = context.getApplicationContext();
AppWidgetManager m = AppWidgetManager.getInstance(c);
int[] ids;
try { ids = m.getAppWidgetIds(new ComponentName(c, PlayerWidget.class)); } catch (Exception e) { return; }
if (ids == null || ids.length == 0) return;
Item it = PlayQueue.get().current();
if (it != null && !it.thumb.equals(artThumb)) {
artThumb = it.thumb;
art = null;
final String want = it.thumb;
App.get().images.load(want, Platform.ART_PX, new ImageLoader.Callback() {
public void onBitmap(final Bitmap b) {
App.get().handler.post(new Runnable() {
public void run() {
if (b == null || !want.equals(artThumb)) return;
art = b;
update(c);
}
});
}
});
}
int state = PlayerService.state();
for (int id : ids) {
try {
m.updateAppWidget(id, PlayerViews.build(c, Platform.widgetLayout(m, id), it, art, state, false));
} catch (Exception e) {
com.minamp.net.DiagLog.log("widget update failed: " + e);
}
}
}
}
+3
View File
@@ -76,6 +76,9 @@ public class SetupActivity extends BaseActivity {
@Override
public boolean onCreateOptionsMenu(android.view.Menu menu) { return false; }
@Override
protected boolean hasOptionsMenu() { return false; }
private void show(View page) {
View[] all = {welcome, pinPage, manual, choose, busy};
for (int i = 0; i < all.length; i++) all[i].setVisibility(all[i] == page ? View.VISIBLE : View.GONE);
+14
View File
@@ -30,6 +30,9 @@ public final class Item {
public int bitrate; // kbps
public long partSize;
public String playlistItemId = "";
/** The part's lyrics streams, timed (LRC) ones first (only in a track's own metadata, not in lists). */
public final List<String> lyricsKeys = new ArrayList<String>();
public int lyricsTimed; // how many of lyricsKeys are timed
public boolean isContainer() { return type == ARTIST || type == ALBUM || type == PLAYLIST; }
public boolean loved() { return userRating >= 9.5; }
@@ -77,6 +80,17 @@ public final class Item {
it.partKey = p.optString("key");
it.partSize = p.optLong("size", 0);
if (it.container.length() == 0) it.container = p.optString("container");
// lyrics: a track can have several (a local .lrc or .txt, LyricFind's text...)
Json.Arr streams = p.optArr("Stream");
for (int i = 0; streams != null && i < streams.size(); i++) {
Json.Obj st = streams.optObj(i);
if (st == null || st.optInt("streamType", 0) != 4 || st.optString("key").length() == 0) continue;
if ("lrc".equalsIgnoreCase(st.optString("format", st.optString("codec")))) {
it.lyricsKeys.add(it.lyricsTimed++, st.optString("key"));
} else {
it.lyricsKeys.add(st.optString("key"));
}
}
}
}
break;
+36
View File
@@ -6,6 +6,7 @@ import java.util.Collections;
import java.util.List;
import java.util.Random;
import com.minamp.net.Http;
import com.minamp.util.Json;
/** Paths into one music library section, plus the playback-URL decisions. */
@@ -150,6 +151,35 @@ public final class Library {
if (t.partKey.length() == 0) throw new IOException(com.minamp.util.Tr.t("no_media", "Track has no playable media"));
}
/** The track's lyrics text, "" when it has none: timed (LRC) if there are, else the longest plain text (a
* local .txt can be a broken copy of LyricFind's). Blocking. */
public String lyrics(Item t) throws IOException {
List<Item> l = server.items("/library/metadata/" + t.ratingKey); // lists leave the streams out
if (l.isEmpty()) return "";
Item full = l.get(0);
String best = "";
for (int i = 0; i < full.lyricsKeys.size(); i++) {
String text = lyricsStream(full.lyricsKeys.get(i));
if (i < full.lyricsTimed && text.trim().length() > 0) return text;
if (text.trim().length() > best.trim().length()) best = text;
}
return best;
}
private String lyricsStream(String key) throws IOException {
Http.Response r = Http.get(server.url(key), server.mediaHeaders(), 20000);
try {
if (r.code == 404) return "";
if (r.code != 200) throw new IOException("HTTP " + r.code);
byte[] d = r.readAll();
String text = new String(d, "UTF-8");
if (text.indexOf('\uFFFD') >= 0) text = new String(d, "ISO-8859-1"); // not UTF-8 after all
return text;
} finally {
r.close();
}
}
public static void shuffle(List<Item> l) {
Collections.shuffle(l, new Random());
}
@@ -295,7 +325,12 @@ public final class Library {
// ------------------------------------------------------------------ reporting
/** For testing against a real server: report no plays or positions (App sets it when the SD card has a file
* named PlexampLite-noreport), so the account's play counts and history stay as they were. */
public static boolean noReport;
public void timeline(Item t, String state, long timeMs) {
if (noReport) return;
try {
server.fetchQuiet("/:/timeline?ratingKey=" + t.ratingKey
+ "&key=" + Plex.enc("/library/metadata/" + t.ratingKey)
@@ -305,6 +340,7 @@ public final class Library {
}
public void scrobble(Item t) {
if (noReport) return;
try {
server.fetchQuiet("/:/scrobble?identifier=com.plexapp.plugins.library&key=" + Plex.enc(t.ratingKey));
} catch (Exception ignored) {}
+95
View File
@@ -0,0 +1,95 @@
package com.minamp.util;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
/** Lyrics text from Plex: LRC ("[01:02.50]text", several stamps per line, [offset:]) or plain text. Pure Java
* (test/LyricsTest.java); the same rules as the Mac app's MNLyrics. */
public final class Lrc {
private Lrc() {}
/** One line; time -1 in plain-text lyrics. */
public static final class Line {
public final long time;
public final String text;
public Line(long time, String text) { this.time = time; this.text = text; }
}
/** The lines, and whether they're timed (synced[0]). Plain text keeps the blank lines between verses. */
public static List<Line> parse(String text, boolean[] synced) {
List<Line> timed = new ArrayList<Line>(), plain = new ArrayList<Line>();
long offset = 0;
String[] raw = text.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1);
for (String r : raw) {
String line = r.trim();
List<Long> times = new ArrayList<Long>();
while (line.startsWith("[")) {
int close = line.indexOf(']');
if (close < 0) break;
String tag = line.substring(1, close);
long t = stamp(tag);
if (t >= 0) times.add(t);
else if (tag.toLowerCase().startsWith("offset:")) offset = parseLong(tag.substring(7).trim());
else if (times.isEmpty() && tag.indexOf(':') >= 0) { line = ""; break; } // [ar:…] [ti:…]
line = line.substring(close + 1).trim();
}
// enhanced LRC word stamps: <00:12.34>
while (true) {
int a = line.indexOf('<');
int b = a < 0 ? -1 : line.indexOf('>', a);
if (a < 0 || b < 0 || stamp(line.substring(a + 1, b)) < 0) break;
line = line.substring(0, a) + line.substring(b + 1);
}
for (long t : times) timed.add(new Line(t, line));
if (times.isEmpty()) plain.add(new Line(-1, line));
}
if (!timed.isEmpty()) {
// LRC's offset: positive shows lines earlier
List<Line> out = new ArrayList<Line>();
for (Line l : timed) out.add(new Line(Math.max(0, l.time - offset), l.text));
Collections.sort(out, new Comparator<Line>() { // stable: lines with the same stamp keep their order
public int compare(Line a, Line b) { return a.time < b.time ? -1 : a.time > b.time ? 1 : 0; }
});
if (synced != null) synced[0] = true;
return out;
}
while (!plain.isEmpty() && plain.get(0).text.length() == 0) plain.remove(0);
while (!plain.isEmpty() && plain.get(plain.size() - 1).text.length() == 0) plain.remove(plain.size() - 1);
if (synced != null) synced[0] = false;
return plain;
}
/** Index of the line being sung at ms (-1 before the first line). */
public static int lineAt(List<Line> lines, long ms) {
int found = -1;
for (int i = 0; i < lines.size(); i++) {
if (lines.get(i).time <= ms) found = i;
else break;
}
return found;
}
/** "mm:ss", "mm:ss.xx", "mm:ss:xx" -> ms, or -1. */
static long stamp(String s) {
String[] p = s.replace('.', ':').split(":", -1);
if (p.length < 2) return -1;
for (String x : p) {
if (x.length() == 0) return -1;
for (int i = 0; i < x.length(); i++) if (x.charAt(i) < '0' || x.charAt(i) > '9') return -1;
}
long ms = parseLong(p[0]) * 60000 + parseLong(p[1]) * 1000;
if (p.length > 2) {
// hundredths ("50") or milliseconds ("500")
long v = parseLong(p[2]);
int n = p[2].length();
ms += n == 1 ? v * 100 : n == 2 ? v * 10 : n == 3 ? v : 0;
}
return ms;
}
private static long parseLong(String s) {
try { return Long.parseLong(s.startsWith("+") ? s.substring(1) : s); } catch (NumberFormatException e) { return 0; }
}
}
+40
View File
@@ -0,0 +1,40 @@
import java.util.List;
import com.minamp.util.Lrc;
/** LRC and plain-text lyrics parsing (com.minamp.util.Lrc). Run:
* javac -d /tmp/lt src/com/minamp/util/Lrc.java test/LyricsTest.java && java -cp /tmp/lt LyricsTest */
public class LyricsTest {
static int fails;
static void check(boolean ok, String what) {
System.out.println((ok ? "PASS " : "FAIL ") + what);
if (!ok) fails++;
}
public static void main(String[] a) {
boolean[] synced = new boolean[1];
List<Lrc.Line> l = Lrc.parse("[ar:Pereza]\n[ti:Pompa]\n[00:12.50]First\r\n[00:05]Zero\n[01:02.123][00:20.00]Twice\n", synced);
check(synced[0], "timed lyrics are synced");
check(l.size() == 4, "a line with two stamps counts twice, tags are dropped (" + l.size() + ")");
check(l.get(0).time == 5000 && l.get(0).text.equals("Zero"), "sorted by time");
check(l.get(1).time == 12500, "hundredths");
check(l.get(3).time == 62123 && l.get(3).text.equals("Twice"), "milliseconds");
check(Lrc.lineAt(l, 4000) == -1, "before the first line");
check(Lrc.lineAt(l, 13000) == 1, "line being sung");
check(Lrc.lineAt(l, 999999) == 3, "after the last line");
l = Lrc.parse("[offset:+500]\n[00:10.00]A <00:10.50>word <00:11.00>by word\n", synced);
check(l.size() == 1 && l.get(0).time == 9500, "offset shows lines earlier");
check(l.get(0).text.equals("A word by word"), "word stamps removed: " + l.get(0).text);
l = Lrc.parse("\n\nVerse one\nline two\n\nVerse two\n\n", synced);
check(!synced[0], "plain text isn't synced");
check(l.size() == 4 && l.get(2).text.length() == 0, "blank lines between verses kept, at the ends dropped");
check(l.get(0).time == -1, "plain lines have no time");
check(Lrc.parse("", synced).isEmpty(), "empty text, no lines");
System.out.println(fails == 0 ? "all passed" : fails + " failed");
System.exit(fails == 0 ? 0 : 1);
}
}
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Fetches what the Android 4.1 build needs beyond tools/setup_toolchain.sh: ExoPlayer 2.18.7 (Google's Maven) and
# Guava (Maven Central), checked against these SHA-256s, into $TOOLS/exoplayer-2.18.7 (TOOLS default ~/android-toolchain).
# 2.18.7 because it's the newest that needs only Guava (2.19 adds AndroidX Core); every 2.x runs on Android 4.1.
set -euo pipefail
T=${TOOLS:-$HOME/android-toolchain}
D=$T/exoplayer-2.18.7
G=https://dl.google.com/dl/android/maven2/com/google/android/exoplayer
M=https://repo1.maven.org/maven2/com/google/guava
mkdir -p "$D"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
get() { # url sha256 file
if [ -f "$D/$3" ] && [ "$(shasum -a 256 "$D/$3" | cut -d' ' -f1)" = "$2" ]; then return; fi
curl -fsSL -o "$tmp/$3" "$1"
[ "$(shasum -a 256 "$tmp/$3" | cut -d' ' -f1)" = "$2" ] || { echo "$3: checksum mismatch"; exit 1; }
mv "$tmp/$3" "$D/$3"
}
for m in core:2b51c5f07aa25f89e4adb7a833073e1a4adc5b87a388bd0045ebc3429b1bf42f \
common:adfef7d3cbd69dd8fcc4ce05642a886c9fbc2764826bc39a6075ba6a0c62d003 \
datasource:cbe67ad9eaf94be7b35d1a625252a79ed5c18601d19f7b4d787acfbcea009208 \
decoder:598c0b444db71770f9b89b4e444e34cb03d5ed2eb5c12fb062afb2f9b05b9a1e \
extractor:c3e34c7b07ecd3c78a75bb7185671030bea4371ac88d854000a856fc05f7eeff \
database:31f0929246fb81296345be8414e7fa080a82deb8d382eb5d875974649030619d; do
a=${m%%:*}; sum=${m#*:}
get "$G/exoplayer-$a/2.18.7/exoplayer-$a-2.18.7.aar" "$sum" "exoplayer-$a.aar"
# an AAR is a zip: the code is classes.jar, the keep rules for apps that shrink it are proguard.txt
unzip -o -q -d "$tmp/$a" "$D/exoplayer-$a.aar"
cp "$tmp/$a/classes.jar" "$D/exoplayer-$a.jar"
if [ -f "$tmp/$a/proguard.txt" ]; then cp "$tmp/$a/proguard.txt" "$D/exoplayer-$a.pro"; fi
rm "$D/exoplayer-$a.aar"
done
get "$M/guava/31.0.1-android/guava-31.0.1-android.jar" 9425a423a4cb9d9db0356300722d9bd8e634cf539f29d97bb84f457cccd16eb8 guava.jar
get "$M/failureaccess/1.0.1/failureaccess-1.0.1.jar" a171ee4c734dd2da837e4b16be9df4661afab72a41adaf31eb84dfdaf936ca26 failureaccess.jar
ls "$D"
+17
View File
@@ -0,0 +1,17 @@
# R8 for the Android 4.1 build (build.sh): shrinks ExoPlayer and Guava to what the app uses and turns their Java 8
# code into what Android 4.1 runs. The app (and its Bouncy Castle copy) stays whole and readable in crash reports.
-keep class com.minamp.** { *; }
-dontobfuscate
# referenced only by annotations, or by code for newer Androids that ExoPlayer guards by version
# (build.sh gives R8 the Android 4.1 API, so anything newer is "missing")
-dontwarn android.**
-dontwarn androidx.annotation.**
-dontwarn org.checkerframework.**
-dontwarn com.google.errorprone.annotations.**
-dontwarn com.google.j2objc.annotations.**
-dontwarn javax.annotation.**
-dontwarn kotlin.**
-dontwarn org.codehaus.mojo.animal_sniffer.**
-dontwarn sun.misc.Unsafe
-dontwarn java.lang.ClassValue
-dontwarn javax.lang.model.element.Modifier
+12
View File
@@ -256,6 +256,18 @@ S = {
"set_delete_downloads": ("Delete all downloads (%1$s)", "Borrar todas as descargas (%1$s)"),
"downloaded": ("Downloaded", "Descargada"),
"quit_app": ("Close Plexamp Lite", "Pechar Plexamp Lite"),
# lyrics (as in the Mac app)
"lyrics": ("Lyrics", "Letra"),
"loading_lyrics": ("Loading lyrics…", "Cargando a letra…"),
"no_lyrics": ("No lyrics for this track", "Esta pista non ten letra"),
"lyrics_failed": ("Couldn't load the lyrics", "Non se puido cargar a letra"),
# home-screen widget, notification and lock-screen buttons
"widget_name": ("Plexamp Lite player", "Reprodutor de Plexamp Lite"),
"previous": ("Previous", "Anterior"),
"next": ("Next", "Seguinte"),
"play_pause": ("Play or pause", "Reproducir ou pausar"),
"stalled": ("Playback got stuck: the network isn't keeping up.", "A reprodución quedou parada: a rede non dá abasto."),
"quality_bt_title": ("Quality over Bluetooth", "Calidade por Bluetooth"),
"set_quality_bt": ("Quality over Bluetooth: %1$s", "Calidade por Bluetooth: %1$s"),