v0.19.0: andTunes plays music on the Rabbit
The Android half of andTunes had been paused on a 1 GB toolchain download (Gradle wrapper, Android Gradle Plugin, Kotlin). None of that was needed: platform 33 and build-tools 34 were already in ~/Android/Sdk, and an app that links no libraries needs five SDK steps, not a build system. andtunes/build.py runs aapt2 -> javac -> R8 -> zipalign -> apksigner and produces a 61 KiB APK. andTunes 0.1.0 is framework-only Java: a six-tile menu that loads nothing, a streaming library.json parse grouped into artists and albums in one pass, one ListActivity for every list (songs and artist songs open with Shuffle all, albums carry art thumbs, search as you type), a Now playing bar under every list, and a white Now Playing screen with square art, a slim scrubber, prev/play/next, shuffle and repeat, and no back button. A foreground PlaybackService owns the queue: audio focus, becoming-noisy, MediaSession and a MediaStyle notification, end of queue pauses, missing files skip, and the last queue and position come back on launch. Black on white, and every size doubled for the R1's override density. Cold start to the menu: 313-338 ms. The scroll wheel turned out to be DPAD_UP/DOWN (stock Generic.kl), not volume. The app takes both in dispatchKeyEvent: lists scroll a row per detent, and the menu and Now Playing get volume. LinTunes side: Connections > Install andTunes on Rabbit... installs the committed APK over adb and grants all-files access, or copies it to Download/ over MTP when there's no adb. Sync fills Buttons/ with default artwork only where a file is missing, so trav's own art is never overwritten. Verified against the dev library only, synced to the R1 and driven with adb input + screencap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9
This commit is contained in:
@@ -21,3 +21,7 @@ itunes-test-library/
|
||||
|
||||
# Synthetic library for development (scripts/make_dev_library.py)
|
||||
/dev-library/
|
||||
|
||||
# andTunes build output (andtunes/build.py); the shipped APK lives in
|
||||
# lintunes/android/ and *is* committed
|
||||
/andtunes/build/
|
||||
|
||||
@@ -325,6 +325,24 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
`device_sync` (so it rides the Syncthing share and both machines agree);
|
||||
keeping it off the `Playlist` keeps it out of the conflict-merge machinery,
|
||||
and **unticking is the only way a playlist comes off the device**.
|
||||
Since Round 50 the app exists: `andtunes/app/` is plain Java against the
|
||||
Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 →
|
||||
zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 +
|
||||
build-tools 34 were already on disk and trav didn't want a 1 GB download
|
||||
over cell for tools that make the same APK. `--ship` copies it to
|
||||
`lintunes/android/andTunes.apk` with its version in `andTunes.json`;
|
||||
**re-run `--ship` whenever the app changes**, since the committed APK is
|
||||
what the self-updater carries and what `install.py` installs.
|
||||
`andtunes/andtunes.keystore` is committed on purpose (both machines must
|
||||
sign identically or `adb install -r` refuses the update). `install.py` is
|
||||
Connections → Install andTunes on Rabbit…: adb as a runtime tool, then the
|
||||
all-files + notification grants (a refused grant is not a failed install —
|
||||
the app's first-run screen covers it), or an MTP copy to `Download/` with
|
||||
no adb. Sync copies `buttons/*.png` (rendered by
|
||||
`andtunes/tools/make_buttons.py`) into the device's `Buttons/` **only
|
||||
where the file is missing** — those names are a contract with the app's
|
||||
`Ui.button()`, which prefers the device copy. `proguard.pro` keeps no
|
||||
debug attributes because R8 8.2 NPEs on javac 21's.
|
||||
|
||||
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
|
||||
right-click menu). A sibling of `device_sync`, reusing its filename helpers
|
||||
|
||||
+39
-34
@@ -15,10 +15,12 @@ track number of every file it just copied, so it writes them into
|
||||
`Music/andTunes/library.json` at sync time and the app parses one file
|
||||
instead of indexing a filesystem. That is the entire architectural bet.
|
||||
|
||||
**Status (2026-09-10): the desktop half is done and shipped** in LinTunes
|
||||
v0.17.0 (`lintunes/andtunes/`, round 47). The Android app has not been
|
||||
started. Work is paused on a slow connection — see *Toolchain* below for the
|
||||
one blocking download.
|
||||
**Status (2026-09-11): andTunes 0.1.0 plays music on the Rabbit.** The
|
||||
desktop half shipped in LinTunes v0.17.0 (round 47); the app itself landed in
|
||||
round 50 (LinTunes v0.19.0), built in plain Java with no Gradle and no
|
||||
download — see *Toolchain*. Cold start to the menu measures 313–338 ms on the
|
||||
R1 (target < 400). Play counts coming back and the format gate are next
|
||||
(`TASKS.md`, phase 3).
|
||||
|
||||
---
|
||||
|
||||
@@ -89,49 +91,51 @@ screen where the stock behaviour is the useful one.
|
||||
|
||||
## Toolchain
|
||||
|
||||
Already done on this machine:
|
||||
Everything is already on this machine, and **nothing else gets downloaded**:
|
||||
|
||||
- `java-21-openjdk-devel` — installed, `javac` on PATH. (Java 25 is also
|
||||
installed; AGP won't run on it, so the Gradle build must be pointed at 21.)
|
||||
- `java-21-openjdk-devel` — `build.py` uses `/usr/lib/jvm/java-21-openjdk`
|
||||
(Java 25 is the system default; R8 and apksigner want 21).
|
||||
- `~/Android/Sdk/platforms/android-33` + `build-tools/34.0.0` (aapt2, d8/R8,
|
||||
zipalign, apksigner). No `cmdline-tools` / `sdkmanager` needed.
|
||||
- `adb` / `fastboot` — in `/usr/local/sbin`, platform-tools 31.0.3.
|
||||
|
||||
**The one thing left, and the reason this is paused: the Android SDK is a
|
||||
~1–1.5 GB download** and trav is on a very weak connection.
|
||||
**No Gradle and no Kotlin** (decided 2026-09-11). The original plan was
|
||||
Kotlin + Gradle, which is another ~1 GB of wrapper, Android Gradle Plugin and
|
||||
Kotlin compiler — tools that produce the same APK. An app that links no
|
||||
libraries needs exactly five SDK steps, so `build.py` runs them directly:
|
||||
aapt2 compile/link → javac (`--release 11`, against `android.jar`) → R8
|
||||
(`--release`, keep rules from aapt2) → zipalign → apksigner. Plain Java also
|
||||
means no Kotlin runtime inside the APK: it's 61 KiB. The source tree is the
|
||||
standard Gradle shape, so Gradle could be dropped in later with no moves.
|
||||
|
||||
```sh
|
||||
# 1. commandline-tools (~150 MB) from developer.android.com/studio
|
||||
# (bottom of the page, "Command line tools only", linux zip)
|
||||
mkdir -p ~/Android/Sdk/cmdline-tools
|
||||
unzip commandlinetools-linux-*_latest.zip -d ~/Android/Sdk/cmdline-tools
|
||||
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
|
||||
To build on a second machine: install `java-21-openjdk-devel`, and copy
|
||||
`~/Android/Sdk/platforms/android-33` and `build-tools/34.0.0` across (≈ 280 MB,
|
||||
no internet needed). `ANDROID_HOME` / `JAVA_HOME` override the default paths.
|
||||
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
|
||||
`andtunes.keystore` is committed on purpose: both machines must sign the same
|
||||
way, or `adb install -r` refuses an update built on the other one. A sideloaded
|
||||
app with no store listing has nothing for the key to protect.
|
||||
|
||||
# 2. the platform we build against (~150 MB total)
|
||||
sdkmanager --licenses
|
||||
sdkmanager "platforms;android-33" "build-tools;34.0.0"
|
||||
```
|
||||
|
||||
Then the **first** Gradle build pulls the wrapper (~130 MB) and the Android
|
||||
Gradle Plugin's dependencies (several hundred MB). After that it's all cached
|
||||
and `./gradlew --offline assembleDebug` works with no connection at all — so
|
||||
the download is a one-time cost, but it is not a small one. Do it on a decent
|
||||
connection.
|
||||
R8 gotcha: `-keepattributes SourceFile,LineNumberTable` makes R8 8.2 die with
|
||||
an internal NPE on javac 21's debug info for an anonymous class, so
|
||||
`proguard.pro` keeps none. Stack traces still carry class and method names
|
||||
(`-dontobfuscate`).
|
||||
|
||||
Fedora note: don't `dnf install android-tools` over the existing
|
||||
`/usr/local/sbin/adb`; two adb versions on PATH fight over the server port.
|
||||
|
||||
---
|
||||
|
||||
## Building and installing (once the SDK is there)
|
||||
## Building and installing
|
||||
|
||||
```sh
|
||||
cd andtunes
|
||||
./gradlew assembleDebug
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
adb shell am start -n me.teafry.andtunes/.MenuActivity
|
||||
python3 andtunes/build.py # -> andtunes/build/andTunes.apk
|
||||
python3 andtunes/build.py --install # ... + adb install -r and launch
|
||||
python3 andtunes/build.py --ship # ... + copy into lintunes/android/
|
||||
|
||||
# after a fresh install by hand, the grants LinTunes' installer does for you:
|
||||
adb shell appops set --uid me.teafry.andtunes MANAGE_EXTERNAL_STORAGE allow
|
||||
adb shell pm grant me.teafry.andtunes android.permission.POST_NOTIFICATIONS
|
||||
|
||||
# the number the whole project is judged on:
|
||||
adb shell am start -W -n me.teafry.andtunes/.MenuActivity | grep TotalTime
|
||||
@@ -143,7 +147,8 @@ Target: menu visible in **under 400 ms**.
|
||||
|
||||
## Design decisions already locked
|
||||
|
||||
- **Kotlin, no Compose, no androidx, no Media3.** Framework `Activity`,
|
||||
- **Java, no Compose, no androidx, no Media3** (Kotlin was the first plan;
|
||||
see *Toolchain* for why it went). Framework `Activity`,
|
||||
`ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`, R8
|
||||
minify. Cold start is the feature; there is nothing to initialize if nothing
|
||||
is linked in. Same instinct as `trash.py` and `sync_identity.py` on the
|
||||
|
||||
+59
-60
@@ -6,11 +6,10 @@ A music player for the Rabbit R1 that never scans anything: LinTunes writes
|
||||
|
||||
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||
|
||||
**Status: paused 2026-09-10** on a weak connection — the Android SDK is a
|
||||
~1–1.5 GB download. Everything needed to resume cold is in `README.md`:
|
||||
device facts, toolchain commands, locked design decisions, and the exact
|
||||
shape of the `library.json` LinTunes already writes. Phases are andTunes'
|
||||
own; which LinTunes round each lands in is decided when it starts.
|
||||
**Status: 0.1.0 plays music (2026-09-11, LinTunes round 50).** Built with
|
||||
`python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see
|
||||
`README.md`, *Toolchain*). Phases are andTunes' own; which LinTunes round
|
||||
each lands in is decided when it starts.
|
||||
|
||||
## Device facts (measured 2026-09-06, `adb shell wm size` etc.)
|
||||
|
||||
@@ -21,76 +20,76 @@ own; which LinTunes round each lands in is decided when it starts.
|
||||
row would be 4.4 mm tall. Do not "fix" this by changing the device density —
|
||||
trav likes it where it is.
|
||||
- Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free.
|
||||
- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**
|
||||
(`KEYCODE_DPAD_UP` / `DPAD_DOWN`). Side/PTT button sits on `mtk-kpd` with
|
||||
KEY_VOLUMEDOWN + KEY_POWER. Headset exposes KEY_PLAYPAUSE, so a MediaSession
|
||||
gets media keys for free.
|
||||
- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**,
|
||||
mapped by the stock `Generic.kl` (checked 2026-09-11 via `dumpsys input`)
|
||||
to **`KEYCODE_DPAD_UP` / `DPAD_DOWN`** — not volume. Side/PTT button sits
|
||||
on `mtk-kpd` with KEY_VOLUMEDOWN + KEY_POWER. Headset exposes
|
||||
KEY_PLAYPAUSE, so a MediaSession gets media keys for free.
|
||||
- USB: set to come up as **`mtp,adb`** whenever the screen is unlocked
|
||||
(`adb shell svc usb setScreenUnlockedFunctions mtp`, 2026-09-11), so the
|
||||
gvfs mount sync uses and the adb install uses are both there at once.
|
||||
|
||||
## Phase 1 — plays a song (v0.1.0)
|
||||
## Phase 1 — plays a song (v0.1.0) ✔
|
||||
|
||||
- [x] `java-21-openjdk-devel` — installed, `javac` on PATH. (Java 25 is also
|
||||
present; AGP won't run on it, so point the build at 21.)
|
||||
- [ ] **Android SDK — the one blocker.** commandline-tools + platform 33 +
|
||||
build-tools into `~/Android/Sdk`, then a first Gradle build to warm the
|
||||
wrapper and AGP caches. ~1–1.5 GB all told; after that
|
||||
`./gradlew --offline assembleDebug` needs no connection. Exact commands
|
||||
in `README.md`.
|
||||
- [ ] Gradle project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33.
|
||||
**No Compose, no androidx, no Media3** — framework `Activity`,
|
||||
`ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`,
|
||||
R8 minify. Cold start is the whole feature; there is nothing to
|
||||
initialize if nothing is linked in.
|
||||
- [ ] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends the user to
|
||||
the toggle. `READ_MEDIA_AUDIO` grants the audio files but **not**
|
||||
`library.json`, which isn't media — that's the whole reason.
|
||||
- [ ] Library loader: parse `library.json` off the main thread into a
|
||||
singleton that survives between activities. Refuse an unknown `format`
|
||||
with a readable message rather than half-reading it.
|
||||
- [ ] `MenuActivity`: six tiles, 2 × 3, **loads nothing**. The sixth reads
|
||||
present; `build.py` points at 21.)
|
||||
- [x] Android SDK: platform 33 + build-tools 34.0.0 were already in
|
||||
`~/Android/Sdk`. **No Gradle, no Kotlin** — `build.py` runs aapt2 →
|
||||
javac → R8 → zipalign → apksigner directly (trav, 2026-09-11: don't
|
||||
pull a GB over cell if it isn't needed; it isn't).
|
||||
- [x] Project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33,
|
||||
framework only: `Activity`, `ListView`, `MediaPlayer`, `MediaSession`,
|
||||
`Notification.MediaStyle`, R8. The APK is 61 KiB.
|
||||
- [x] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends the user to
|
||||
the toggle (LinTunes' installer grants it over adb, so it rarely shows).
|
||||
- [x] Library loader: `android.util.JsonReader` streaming parse off the main
|
||||
thread into a process singleton; artists/albums grouped in one pass;
|
||||
an unknown `format` gets a readable message; reloads when
|
||||
`library.json` changes (a re-sync) the next time the menu resumes.
|
||||
- [x] `MenuActivity`: six tiles, 2 × 3, **loads nothing**. The sixth reads
|
||||
"Now Playing" or "Shuffle All" depending on the service.
|
||||
- [ ] `ListActivity` in songs mode + "Shuffle all" header row.
|
||||
- [ ] `PlaybackService` (foreground) + `NowPlayingActivity`: white
|
||||
background, square art, slim scrubber, ◀ ⏯ ▶, shuffle. **No back
|
||||
button** — Android's own is always on screen.
|
||||
- [ ] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification.
|
||||
- [ ] End of queue **pauses** unless repeat is on. Repeat: off / all / one.
|
||||
- [ ] Verify with `adb shell am start -W` — target: menu visible < 400 ms.
|
||||
- [x] `ListActivity` in songs mode + "Shuffle all" header row.
|
||||
- [x] `PlaybackService` (foreground) + `NowPlayingActivity`: white
|
||||
background, square art, slim scrubber, ◀ ⏯ ▶, shuffle, repeat.
|
||||
**No back button.**
|
||||
- [x] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification.
|
||||
- [x] End of queue **pauses** unless repeat is on. Repeat: off / all / one.
|
||||
- [x] `adb shell am start -W`: **313–338 ms** cold to the menu (551 ms on the
|
||||
very first launch after install, which includes dex verification).
|
||||
|
||||
## Phase 2 — the rest of the screens
|
||||
## Phase 2 — the rest of the screens ✔
|
||||
|
||||
- [ ] Playlists, artists (→ albums + "All songs" + shuffle for that artist),
|
||||
albums, search.
|
||||
- [ ] Album art thumbs in album rows, full art on now playing.
|
||||
- [ ] The "Now playing" bar at the bottom of every list screen.
|
||||
- [ ] Scroll wheel → list scrolling, and volume on the now-playing screen.
|
||||
Consume `KEYCODE_VOLUME_UP/DOWN` **and** `DPAD_UP/DOWN` in both
|
||||
`onKeyDown` and `onKeyUp` — the ROM's key layout decides which arrives,
|
||||
and claiming them in a focused activity affects no other app. First
|
||||
check `/system/usr/keylayout/` on the device; see `README.md`.
|
||||
- [ ] Replaceable button images: load `Buttons/<name>.png` from the device
|
||||
folder when present, else the bundled drawable. LinTunes ships defaults
|
||||
into `Buttons/` only when the file is missing, so the user's own
|
||||
artwork is never overwritten.
|
||||
- [ ] Missing file (synced then deleted) skips with a toast, doesn't crash
|
||||
- [x] Playlists, artists (→ "All songs" + albums; all songs has its own
|
||||
shuffle), albums, search (artists, albums and songs as you type).
|
||||
- [x] Album art thumbs in album rows, full art on now playing.
|
||||
- [x] The "Now playing" bar at the bottom of every list screen; the playing
|
||||
song is drawn inverted in any list it appears in.
|
||||
- [x] Scroll wheel → list scrolling (one row per detent), volume on the menu
|
||||
and now-playing screens. Taken in `dispatchKeyEvent`, DPAD *and*
|
||||
VOLUME codes, down *and* up, so the search box can't swallow it.
|
||||
- [x] Replaceable button images: `Buttons/<name>.png` beats the bundled
|
||||
drawable, for the six menu tiles plus ◀ ▶ ⏯. LinTunes ships the
|
||||
defaults into `Buttons/` only where the file is missing.
|
||||
- [x] Missing file (synced then deleted) skips with a toast, doesn't stop
|
||||
the queue.
|
||||
- [ ] Resume last track + position on launch.
|
||||
- [x] Resume last queue + track + position on launch (paused).
|
||||
|
||||
## Phase 3 — install from LinTunes, counts coming back
|
||||
|
||||
- [ ] `Connections → Install andTunes on Rabbit…`: `shutil.which("adb")`
|
||||
(the `ffmpeg_available()` runtime-tool pattern — never a pip dep), then
|
||||
`adb install -r`; fall back to copying the APK over MTP for a manual
|
||||
tap. APK committed at `lintunes/android/andTunes.apk` with
|
||||
`package_data`, read via `importlib.resources` like `export/templates`.
|
||||
- [x] `Connections → Install andTunes on Rabbit…` (LinTunes 0.19.0):
|
||||
`adb install -r` + the two permission grants; says Install / Update /
|
||||
Reinstall from the version on the device; with no adb, copies the APK
|
||||
to `Download/` over MTP for a manual tap. APK committed at
|
||||
`lintunes/android/andTunes.apk` (+ `andTunes.json`, its version).
|
||||
- [ ] Format gate shaped like `export/web_support.py`: deny-by-default on
|
||||
suffix, AIFF and protected AAC refused, anything else unplayable
|
||||
converted to **FLAC** (never a lossy re-encode).
|
||||
suffix, protected AAC refused, anything else unplayable converted to
|
||||
**FLAC** (never a lossy re-encode).
|
||||
- [ ] Play counts back: the app writes `plays/andtunes-<installid>.json` in
|
||||
the per-machine-totals shape of `storage/play_journal.py`; sync copies
|
||||
it back and folds it in. The R1 becomes one more machine in the Round 38
|
||||
model — no new merge rules.
|
||||
- [ ] Retire `Sync Playlist to Rabbit (Auxio)` once andTunes covers it, and
|
||||
offer a one-off cleanup of the old `Music/<Playlist>/` folders.
|
||||
- [ ] Retire `Sync Playlist to Rabbit (Auxio)` now that andTunes covers it.
|
||||
(trav's R1 has no old `Music/<Playlist>/` folders, so no cleanup.)
|
||||
|
||||
## Parked
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="me.teafry.andtunes">
|
||||
|
||||
<!-- library.json isn't media, so READ_MEDIA_AUDIO can't open it: all-files
|
||||
access is the whole reason this app can skip scanning. LinTunes grants
|
||||
it over adb at install; the first-run screen is the fallback. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:label="andTunes"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:theme="@style/AndTunes"
|
||||
android:allowBackup="false">
|
||||
|
||||
<activity
|
||||
android:name=".MenuActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".ListActivity" android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity android:name=".NowPlayingActivity" android:exported="false"
|
||||
android:launchMode="singleTop" />
|
||||
|
||||
<service android:name=".PlaybackService" android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,109 @@
|
||||
package me.teafry.andtunes;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Bundle;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
/**
|
||||
* What every andTunes screen shares: the scroll wheel, and the "Now playing"
|
||||
* bar along the bottom.
|
||||
*
|
||||
* <p>The R1's wheel arrives as DPAD_UP / DPAD_DOWN (its key layout is the stock
|
||||
* Generic.kl), but a ROM could map it to VOLUME_UP / DOWN instead, so both are
|
||||
* claimed. They're taken in {@link #dispatchKeyEvent} — before any focused view
|
||||
* (the search box, a list) can swallow them — and on key-up as well as
|
||||
* key-down, or the system can still act on the release. This only applies
|
||||
* while an andTunes screen is in front; volume behaves normally everywhere else.
|
||||
*/
|
||||
abstract class BaseActivity extends Activity implements PlaybackService.Listener {
|
||||
protected AudioManager audio;
|
||||
private TextView nowBar;
|
||||
|
||||
@Override protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
audio = getSystemService(AudioManager.class);
|
||||
setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||
}
|
||||
|
||||
/** Lay out {@code content} above a now-playing bar (shown only when useful). */
|
||||
protected void setScreen(View content, boolean withNowBar) {
|
||||
LinearLayout column = new LinearLayout(this);
|
||||
column.setOrientation(LinearLayout.VERTICAL);
|
||||
column.setBackgroundColor(Ui.WHITE);
|
||||
column.addView(content, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
if (withNowBar) {
|
||||
nowBar = Ui.text(this, 24, true, Ui.WHITE);
|
||||
nowBar.setBackgroundColor(Ui.BLACK);
|
||||
nowBar.setGravity(Gravity.CENTER_VERTICAL);
|
||||
int pad = Ui.dp(this, 20);
|
||||
nowBar.setPadding(pad, 0, pad, 0);
|
||||
nowBar.setOnClickListener(v -> openNowPlaying());
|
||||
column.addView(nowBar, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 80)));
|
||||
}
|
||||
setContentView(column);
|
||||
}
|
||||
|
||||
protected void openNowPlaying() {
|
||||
startActivity(new Intent(this, NowPlayingActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT));
|
||||
}
|
||||
|
||||
@Override protected void onResume() {
|
||||
super.onResume();
|
||||
PlaybackService.addListener(this);
|
||||
onPlaybackChanged();
|
||||
}
|
||||
|
||||
@Override protected void onPause() {
|
||||
PlaybackService.removeListener(this);
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override public void onPlaybackChanged() {
|
||||
if (nowBar == null) return;
|
||||
PlaybackService s = PlaybackService.get();
|
||||
Library.Track t = s != null ? s.current() : null;
|
||||
if (t == null) {
|
||||
nowBar.setVisibility(View.GONE);
|
||||
return;
|
||||
}
|
||||
nowBar.setVisibility(View.VISIBLE);
|
||||
nowBar.setText((s.isPlaying() ? "Now playing: " : "Paused: ") + t.title);
|
||||
}
|
||||
|
||||
@Override public boolean dispatchKeyEvent(KeyEvent e) {
|
||||
int dir = wheelDirection(e.getKeyCode());
|
||||
if (dir == 0) return super.dispatchKeyEvent(e);
|
||||
if (e.getAction() == KeyEvent.ACTION_DOWN) onWheel(dir);
|
||||
return true;
|
||||
}
|
||||
|
||||
static int wheelDirection(int code) {
|
||||
switch (code) {
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_VOLUME_UP:
|
||||
return 1;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_VOLUME_DOWN:
|
||||
return -1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** One wheel detent. Volume unless the screen has something to scroll. */
|
||||
protected void onWheel(int dir) {
|
||||
audio.adjustStreamVolume(AudioManager.STREAM_MUSIC,
|
||||
dir > 0 ? AudioManager.ADJUST_RAISE : AudioManager.ADJUST_LOWER,
|
||||
AudioManager.FLAG_SHOW_UI);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package me.teafry.andtunes;
|
||||
|
||||
import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.JsonReader;
|
||||
import android.util.JsonToken;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Everything andTunes knows, read from the one file LinTunes wrote.
|
||||
*
|
||||
* This is the whole bet: no MediaStore, no directory walk, no tag reads.
|
||||
* {@code library.json} is parsed with a streaming reader on a background
|
||||
* thread (kicked off by the menu, which itself loads nothing), then artists
|
||||
* and albums are grouped from the flat track list in one pass. The result is
|
||||
* immutable and lives for the life of the process, so every screen after the
|
||||
* first is instant.
|
||||
*/
|
||||
final class Library {
|
||||
static final int FORMAT = 1;
|
||||
static final String UNKNOWN_ARTIST = "Unknown Artist";
|
||||
static final String UNKNOWN_ALBUM = "Unknown Album";
|
||||
|
||||
static final class Track {
|
||||
long id;
|
||||
String title = "", path = "", artist = "", albumArtist = "", album = "", art = "";
|
||||
int year, number, disc, secs;
|
||||
String sortKey, haystack;
|
||||
Album albumRef;
|
||||
|
||||
File file() { return new File(root(), path); }
|
||||
String artistLabel() { return artist.isEmpty() ? UNKNOWN_ARTIST : artist; }
|
||||
}
|
||||
|
||||
static final class Album {
|
||||
String key, name, artist, art = "";
|
||||
int year;
|
||||
String sortKey;
|
||||
final ArrayList<Track> tracks = new ArrayList<>();
|
||||
}
|
||||
|
||||
static final class Artist {
|
||||
String name, sortKey;
|
||||
final ArrayList<Album> albums = new ArrayList<>();
|
||||
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
|
||||
}
|
||||
|
||||
static final class Playlist {
|
||||
String id, name;
|
||||
final ArrayList<Track> tracks = new ArrayList<>(); // playing order
|
||||
}
|
||||
|
||||
interface Listener { void onLibrary(Library library, String error); }
|
||||
|
||||
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
|
||||
final ArrayList<Artist> artists = new ArrayList<>();
|
||||
final ArrayList<Album> albums = new ArrayList<>();
|
||||
final ArrayList<Playlist> playlists = new ArrayList<>();
|
||||
final HashMap<Long, Track> byId = new HashMap<>();
|
||||
final HashMap<String, Artist> artistsByName = new HashMap<>();
|
||||
final HashMap<String, Album> albumsByKey = new HashMap<>();
|
||||
long stamp; // library.json mtime + size, to notice a re-sync
|
||||
|
||||
// ---- process-wide instance ----
|
||||
|
||||
private static final Handler MAIN = new Handler(Looper.getMainLooper());
|
||||
private static Library sLibrary;
|
||||
private static String sError;
|
||||
private static boolean sLoading;
|
||||
private static final ArrayList<Listener> sWaiting = new ArrayList<>();
|
||||
|
||||
static File root() {
|
||||
return new File(Environment.getExternalStorageDirectory(), "Music/andTunes");
|
||||
}
|
||||
|
||||
static File manifest() { return new File(root(), "library.json"); }
|
||||
|
||||
static Library get() { return sLibrary; }
|
||||
|
||||
/** Start loading if nothing is loaded yet, or the file changed under us. */
|
||||
static void preload() {
|
||||
if (sLoading) return;
|
||||
if (sLibrary != null && sLibrary.stamp == stampOf(manifest())) return;
|
||||
sLoading = true;
|
||||
new Thread(() -> {
|
||||
Library lib = null;
|
||||
String err = null;
|
||||
try {
|
||||
lib = load(manifest());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
err = describe(e);
|
||||
}
|
||||
final Library done = lib;
|
||||
final String error = err;
|
||||
MAIN.post(() -> {
|
||||
sLoading = false;
|
||||
if (done != null) { sLibrary = done; sError = null; }
|
||||
else if (sLibrary == null) sError = error;
|
||||
ArrayList<Listener> waiting = new ArrayList<>(sWaiting);
|
||||
sWaiting.clear();
|
||||
for (Listener l : waiting) l.onLibrary(sLibrary, sError);
|
||||
});
|
||||
}, "library-load").start();
|
||||
}
|
||||
|
||||
/** Call back on the main thread once a library (or an error) is ready. */
|
||||
static void whenReady(Listener listener) {
|
||||
if (!sLoading && (sLibrary != null || sError != null)) {
|
||||
listener.onLibrary(sLibrary, sError);
|
||||
return;
|
||||
}
|
||||
sWaiting.add(listener);
|
||||
preload();
|
||||
}
|
||||
|
||||
static void forget(Listener listener) { sWaiting.remove(listener); }
|
||||
|
||||
private static long stampOf(File f) { return f.lastModified() * 31 + f.length(); }
|
||||
|
||||
private static String describe(Exception e) {
|
||||
if (e instanceof java.io.FileNotFoundException) {
|
||||
return "No music yet.\n\nIn LinTunes: Connections → Sync to Rabbit.";
|
||||
}
|
||||
String msg = e.getMessage();
|
||||
return msg != null && msg.startsWith("andTunes:") ? msg.substring(9)
|
||||
: "Couldn't read library.json:\n" + e;
|
||||
}
|
||||
|
||||
// ---- parsing ----
|
||||
|
||||
static Library load(File file) throws IOException {
|
||||
Library lib = new Library();
|
||||
lib.stamp = stampOf(file);
|
||||
HashMap<String, List<Long>> playlistIds = new HashMap<>();
|
||||
int format = -1;
|
||||
try (JsonReader r = new JsonReader(new InputStreamReader(
|
||||
new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||
r.beginObject();
|
||||
while (r.hasNext()) {
|
||||
String key = r.nextName();
|
||||
if (key.equals("format")) {
|
||||
format = r.nextInt();
|
||||
checkFormat(format);
|
||||
} else if (key.equals("tracks")) {
|
||||
r.beginArray();
|
||||
while (r.hasNext()) lib.addTrack(readTrack(r));
|
||||
r.endArray();
|
||||
} else if (key.equals("playlists")) {
|
||||
r.beginArray();
|
||||
while (r.hasNext()) lib.playlists.add(readPlaylist(r, playlistIds));
|
||||
r.endArray();
|
||||
} else {
|
||||
r.skipValue();
|
||||
}
|
||||
}
|
||||
r.endObject();
|
||||
}
|
||||
checkFormat(format);
|
||||
for (Playlist p : lib.playlists) {
|
||||
for (Long id : playlistIds.get(p.id)) {
|
||||
Track t = lib.byId.get(id);
|
||||
if (t != null) p.tracks.add(t);
|
||||
}
|
||||
}
|
||||
lib.group();
|
||||
return lib;
|
||||
}
|
||||
|
||||
private static void checkFormat(int format) {
|
||||
if (format != FORMAT) {
|
||||
throw new IllegalStateException("andTunes:This library was written in format "
|
||||
+ format + ", and this andTunes only reads format " + FORMAT
|
||||
+ ".\n\nIn LinTunes: Connections → Install andTunes on Rabbit.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Track readTrack(JsonReader r) throws IOException {
|
||||
Track t = new Track();
|
||||
r.beginObject();
|
||||
while (r.hasNext()) {
|
||||
String key = r.nextName();
|
||||
if (r.peek() == JsonToken.NULL) { r.skipValue(); continue; }
|
||||
switch (key) {
|
||||
case "id": t.id = r.nextLong(); break;
|
||||
case "title": t.title = r.nextString(); break;
|
||||
case "path": t.path = r.nextString(); break;
|
||||
case "artist": t.artist = r.nextString(); break;
|
||||
case "album_artist": t.albumArtist = r.nextString(); break;
|
||||
case "album": t.album = r.nextString(); break;
|
||||
case "art": t.art = r.nextString(); break;
|
||||
case "year": t.year = r.nextInt(); break;
|
||||
case "track": t.number = r.nextInt(); break;
|
||||
case "disc": t.disc = r.nextInt(); break;
|
||||
case "secs": t.secs = r.nextInt(); break;
|
||||
default: r.skipValue();
|
||||
}
|
||||
}
|
||||
r.endObject();
|
||||
if (t.title.isEmpty()) {
|
||||
String name = new File(t.path).getName();
|
||||
int dot = name.lastIndexOf('.');
|
||||
t.title = dot > 0 ? name.substring(0, dot) : name;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
private static Playlist readPlaylist(JsonReader r, HashMap<String, List<Long>> ids)
|
||||
throws IOException {
|
||||
Playlist p = new Playlist();
|
||||
ArrayList<Long> members = new ArrayList<>();
|
||||
r.beginObject();
|
||||
while (r.hasNext()) {
|
||||
String key = r.nextName();
|
||||
switch (key) {
|
||||
case "id": p.id = r.nextString(); break;
|
||||
case "name": p.name = r.nextString(); break;
|
||||
case "tracks":
|
||||
r.beginArray();
|
||||
while (r.hasNext()) members.add(r.nextLong());
|
||||
r.endArray();
|
||||
break;
|
||||
default: r.skipValue();
|
||||
}
|
||||
}
|
||||
r.endObject();
|
||||
if (p.id == null) p.id = "p" + ids.size();
|
||||
if (p.name == null) p.name = "Untitled";
|
||||
ids.put(p.id, members);
|
||||
return p;
|
||||
}
|
||||
|
||||
private void addTrack(Track t) {
|
||||
t.sortKey = sortKey(t.title);
|
||||
t.haystack = (t.title + "\n" + t.artist + "\n" + t.albumArtist + "\n" + t.album)
|
||||
.toLowerCase(Locale.ROOT);
|
||||
tracks.add(t);
|
||||
byId.put(t.id, t);
|
||||
}
|
||||
|
||||
/** Artists and albums, grouped from the flat list in one pass. */
|
||||
private void group() {
|
||||
Collections.sort(tracks, (a, b) -> a.sortKey.compareTo(b.sortKey));
|
||||
for (Track t : tracks) {
|
||||
// Grouped by album artist when there is one, the way the desktop
|
||||
// files them, so a compilation stays one album under one name.
|
||||
String who = !t.albumArtist.isEmpty() ? t.albumArtist
|
||||
: !t.artist.isEmpty() ? t.artist : UNKNOWN_ARTIST;
|
||||
String albumName = t.album.isEmpty() ? UNKNOWN_ALBUM : t.album;
|
||||
String key = who.toLowerCase(Locale.ROOT) + " | ||||