Private
Public Access
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:
1 parent
2e7476669e
commit
13f29e26c4
39 files changed
+1685
-119
No files matched your search
+11
-2
@@ -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" />
|
||||
|
||||
@@ -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).
|
||||
@@ -1,46 +1,99 @@
|
||||
#!/bin/bash
|
||||
# Builds PlexampLite.apk for Android 2.2+ without the Android SDK/Gradle:
|
||||
# aapt2 (resources) -> javac (Java 8 bytecode vs android-8 API) -> dx (dex 035) -> signapk.py (v1/SHA-1 + zipalign).
|
||||
# Builds Plexamp Lite without the Android SDK or Gradle, one APK per Android level (same package and key):
|
||||
# build/PlexampLite.apk Android 2.2 and later (Galaxy Mini) src + platform/api8, against android-8
|
||||
# build/PlexampLite-api16.apk Android 4.1 and later (Xperia J...) src + platform/api16 + ExoPlayer, against android-16
|
||||
# Each: aapt2 (resources; api16 overlays platform/api16/res) -> javac (Java 8 bytecode) -> then
|
||||
# api8: Dalvik 2.x link check -> dx (dex 035)
|
||||
# api16: R8 (shrinks ExoPlayer and Guava, desugars Java 8, dex 035 for Android 4.1)
|
||||
# -> signapk.py (v1 / SHA-1 in the classic SignApk format, plus zipalign).
|
||||
# versionCode = the manifest's x 100 + the level, so a phone that moves up to Android 4.1 gets the newer build as an update.
|
||||
# Toolchain: tools/setup_toolchain.sh, plus tools/fetch_exoplayer.sh and R8 (d8.jar from build-tools 34) for api16.
|
||||
# ./build.sh both
|
||||
# ./build.sh 16 one level
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
T=${TOOLS:-$PWD/toolchain}
|
||||
ANDROID_JAR=$T/platforms/android-8/android.jar
|
||||
T=${TOOLS:-$HOME/android-toolchain}
|
||||
BC_SRC=$T/bcsrc
|
||||
OUT=build
|
||||
rm -rf $OUT && mkdir -p $OUT/res $OUT/gen $OUT/classes
|
||||
EXO=$T/exoplayer-2.18.7
|
||||
R8=${R8:-$T/calendar-tools/d8.jar} # build-tools' d8.jar holds R8 too
|
||||
PY=${PY:-$T/venv/bin/python} # signapk.py needs the cryptography package
|
||||
[ -x "$PY" ] || PY=python3
|
||||
LEVELS=${*:-8 16}
|
||||
# a JDK 11+ (R8 needs it): the macOS java stub answers "javac" but can't run it
|
||||
if ! javac -version >/dev/null 2>&1 || javac -version 2>&1 | grep -q '1\.8'; then
|
||||
for j in "${JAVA_HOME:-}" /opt/homebrew/opt/openjdk@17 /opt/homebrew/opt/openjdk $(ls -d "$HOME"/tools/jdk-17* 2>/dev/null); do
|
||||
if [ -n "$j" ] && [ -x "$j/bin/javac" ]; then export JAVA_HOME=$j PATH=$j/bin:$PATH; break; fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "== resources"
|
||||
$T/aapt2 compile --dir res -o $OUT/res/compiled.zip
|
||||
$T/aapt2 link -o $OUT/base.apk -I $ANDROID_JAR --manifest AndroidManifest.xml \
|
||||
--java $OUT/gen -A assets --min-sdk-version 8 --target-sdk-version 10 \
|
||||
--no-version-vectors $OUT/res/compiled.zip
|
||||
|
||||
echo "== java"
|
||||
find src $OUT/gen -name '*.java' > $OUT/sources.txt
|
||||
javac -nowarn -Xlint:-options -source 8 -target 8 -encoding UTF-8 -g:source,lines \
|
||||
-bootclasspath $ANDROID_JAR -sourcepath "src:$BC_SRC:$OUT/gen" \
|
||||
-d $OUT/classes @$OUT/sources.txt 2>&1 | grep -v -E '^Picked up|^Note:' || true
|
||||
test -f $OUT/classes/com/minamp/HomeActivity.class
|
||||
echo "== strings"
|
||||
python3 tools/strings.py
|
||||
|
||||
echo "== text fit check (240x320, all languages and sizes)"
|
||||
if [ -f "$T/fonts/droidsans-regular.ttf" ]; then
|
||||
python3 tools/check_fit.py "$T/fonts" || { echo "Some texts don't fit their space (see above)"; exit 1; }
|
||||
"$PY" tools/check_fit.py "$T/fonts" || { echo "Some texts don't fit their space (see above)"; exit 1; }
|
||||
else
|
||||
echo "(skipped: run tools/setup_toolchain.sh to fetch the Droid Sans font)"
|
||||
fi
|
||||
|
||||
echo "== Dalvik 2.x link check"
|
||||
python3 tools/scan_dalvik.py $OUT/classes $ANDROID_JAR > $OUT/dalvik-scan.txt
|
||||
if ! grep -q "^total patterns: 0$" $OUT/dalvik-scan.txt; then
|
||||
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
|
||||
fi
|
||||
java -Xmx2g -jar $T/dx.jar --dex --output=$OUT/classes.dex $OUT/classes 2>&1 | grep -v '^Picked up' || true
|
||||
else
|
||||
echo "-- R8"
|
||||
(cd $OUT/classes && zip -q -r ../app.jar .)
|
||||
PGCONF=$(for f in tools/r8-rules.pro $EXO/*.pro; do printf -- '--pg-conf %s ' "$f"; done)
|
||||
java -Xmx2g -cp "$R8" com.android.tools.r8.R8 --release --min-api $API --lib $ANDROID_JAR \
|
||||
--map-diagnostics info none --output $OUT $PGCONF $OUT/app.jar $EXO/*.jar 2>&1 | grep -v '^Picked up' || true
|
||||
test -f $OUT/classes.dex
|
||||
# one dex only: Android 4.1 can't load a second without a multidex library
|
||||
if [ -f $OUT/classes2.dex ]; then echo "R8 made more than one dex file (over 64K methods)"; exit 1; fi
|
||||
fi
|
||||
|
||||
echo "== dex"
|
||||
java -Xmx2g -jar $T/dx.jar --dex --output=$OUT/classes.dex $OUT/classes 2>&1 | grep -v '^Picked up' || true
|
||||
|
||||
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
|
||||
cp $OUT/base.apk $OUT/unsigned.apk
|
||||
(cd $OUT && zip -q unsigned.apk classes.dex)
|
||||
# v1 signature in the classic Android SignApk format (jarsigner's modern output won't install on Android <= 4.2)
|
||||
$PY tools/signapk.py ${KEYSTORE:-signing/minamp.keystore} ${KEYPASS:-minamp123} $OUT/unsigned.apk $APK
|
||||
ls -l $APK
|
||||
done
|
||||
@@ -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"; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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`.
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+35
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user