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:
2026-09-11 14:25:55 -05:00
co-authored by Claude Opus 5
parent 8188a5b389
commit 9483d8951b
51 changed files with 2963 additions and 98 deletions
+59 -60
View File
@@ -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