andTunes phase 3. The R1 is now one more machine in the Round 38 play-count model, and nothing lands on it that Android can't play. Play counts: andTunes 0.2.0 counts a play on a natural finish (LinTunes' rule) and keeps Music/andTunes/plays/andtunes-<install id>.json in exactly the per-machine-totals journal shape, written beside the old one and renamed over it. Each sync first folds it into <data_dir>/plays/ with a new play_journal.merge_totals, the per-track max: the file now has two homes and both desktops may bring it back, so the max converges and an older copy can never pull a count down. Nothing is written when nothing moved, and PlayJournal.load needed no change. Format gate: the planner reuses export/web_support.conversion_for outright, since Android's MediaPlayer decodes the browser's set. FairPlay is refused and reported; ALAC, AIFF and oddities land as FLAC, converted once into a per-track cache and size-diffed after that. With no ffmpeg, the export's "sync without them?" question. A file ffmpeg can't read costs that song, not the sync. "Sync Playlist to Rabbit (Auxio)" is retired from the menu; device_sync's helpers stay because export and andTunes import them. The dev fixture grew a real ALAC track, a Protected AAC .m4p and an "Odd Formats" playlist. Verified on the R1 with it: AIFF and ALAC arrived as FLAC and played, the FairPlay track was refused, four plays came back on the next sync and each track's effective count rose by exactly one, and a third sync copied nothing. Found along the way: USB re-enumeration can wedge gvfsd-mtp, after which anything touching the mount (find_device, the GUI tests) hangs in an uninterruptible wait. CLAUDE.md now has the recovery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ZCVBTJRFJ2XfMshu2gtUv
215 lines
9.6 KiB
Markdown
215 lines
9.6 KiB
Markdown
# andTunes
|
||
|
||
A music player for the Rabbit R1 that **never scans anything**.
|
||
|
||
Every other Android player rebuilds its library on the device. Auxio reads
|
||
Android's MediaStore on each launch, which on a Helio P35 is the "your songs
|
||
will show up here" hang — and Musicolet, AIMP, Vinyl, Retro, Gramophone and
|
||
Fossify are all MediaStore-backed too. Symfonium and VLC keep their own
|
||
database but still scan the device to fill it, and both assume a phone-sized
|
||
screen. (trav sideloaded Musicolet in September 2026 to check: "it was…
|
||
fine?" — nothing off the shelf is built around already knowing the answer.)
|
||
|
||
LinTunes *does* already know the answer. It has the artist, album, year and
|
||
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-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). Round 51 (andTunes 0.2.0, LinTunes v0.20.0) finished
|
||
phase 3: play counts come back to LinTunes on every sync, and songs Android
|
||
can't play are converted to FLAC or, for FairPlay, left out and reported.
|
||
|
||
---
|
||
|
||
## The device
|
||
|
||
Measured on trav's R1, not taken from the internet (which says 240×282 and is
|
||
wrong):
|
||
|
||
| | |
|
||
|---|---|
|
||
| Panel | **480 × 640 px**, physical density 320, **override density 160** |
|
||
| Build | Android **13**, API **33**, `gsi_r1-userdebug` (r1_escape AOSP flash) |
|
||
| CPU | MediaTek Helio P35, arm64-v8a, 4 GB RAM |
|
||
| Storage | 128 GB, ~65 GB free |
|
||
| Scroll wheel | input device `och1970_holl_key` → **KEY_UP / KEY_DOWN** |
|
||
| Side/PTT button | `mtk-kpd`: KEY_VOLUMEDOWN + KEY_POWER |
|
||
| Headset | `mt63xx-accdet`: KEY_PLAYPAUSE → a MediaSession gets media keys free |
|
||
|
||
### The dp trap
|
||
|
||
Density is overridden to 160, so an app sees a 480 × 640 **dp** canvas — but
|
||
the panel is 2.88", about 278 real ppi. **One dp is roughly half its usual
|
||
physical size here.** A stock 48 dp list row is 4.4 mm tall and unusable.
|
||
|
||
> **Rule: double every stock dp value.** Body text 28–32 sp, list rows ≥ 88 dp,
|
||
> menu tiles ~213 dp. A 2 × 3 grid gives six 240 × 213 dp buttons, ≈ 22 × 19 mm
|
||
> each.
|
||
|
||
Do **not** "fix" this by changing the device's density. trav has it where he
|
||
wants it and asked for it to be left alone.
|
||
|
||
### The scroll wheel, and whether we can have it
|
||
|
||
trav reports the wheel acts as **volume** in every app, even though the kernel
|
||
device emits KEY_UP/KEY_DOWN — so the ROM ships a key layout mapping those to
|
||
`KEYCODE_VOLUME_UP`/`DOWN`. Confirm which one actually arrives when the device
|
||
is next plugged in:
|
||
|
||
```sh
|
||
adb shell 'ls /system/usr/keylayout/ | grep -i holl'
|
||
adb shell 'cat /system/usr/keylayout/och1970_holl_key.kl' # if it exists
|
||
```
|
||
|
||
Either way **an app can absolutely claim it for itself**, without touching a
|
||
system file or affecting any other app. A foreground Activity sees key events
|
||
before the system's volume handling, so:
|
||
|
||
```kotlin
|
||
override fun onKeyDown(code: Int, e: KeyEvent): Boolean = when (code) {
|
||
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_DPAD_UP -> { wheelUp(); true }
|
||
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_DPAD_DOWN -> { wheelDown(); true }
|
||
else -> super.onKeyDown(code, e)
|
||
}
|
||
```
|
||
|
||
Handle **both** mappings so it works whichever the ROM sends, and consume
|
||
`onKeyUp` for the same codes too — returning true only from `onKeyDown` can
|
||
still let the system act on the release. Returning true is what stops the
|
||
volume UI appearing. This is ordinary Android (camera apps do it for the
|
||
shutter); it applies only while an andTunes activity is focused, so volume
|
||
behaves normally everywhere else.
|
||
|
||
Worth deciding once it's testable: the wheel scrolls lists everywhere, and on
|
||
the now-playing screen it probably *should* stay volume — that's the one
|
||
screen where the stock behaviour is the useful one.
|
||
|
||
---
|
||
|
||
## Toolchain
|
||
|
||
Everything is already on this machine, and **nothing else gets downloaded**:
|
||
|
||
- `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.
|
||
|
||
**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.
|
||
|
||
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.
|
||
|
||
`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.
|
||
|
||
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
|
||
|
||
```sh
|
||
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
|
||
```
|
||
|
||
Target: menu visible in **under 400 ms**.
|
||
|
||
---
|
||
|
||
## Design decisions already locked
|
||
|
||
- **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
|
||
LinTunes side.
|
||
- **Package** `me.teafry.andtunes`, minSdk 26, targetSdk 33, arm64-v8a.
|
||
- **`MANAGE_EXTERNAL_STORAGE`** with a first-run screen sending 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. Fallback if it
|
||
turns out to be blocked: a one-time SAF folder pick.
|
||
- **Screens are activities**, so "the last screen we were at" is Android's own
|
||
back stack with no bookkeeping. `MenuActivity` (six tiles, loads nothing),
|
||
one generic `ListActivity` parameterised for every list in the spec,
|
||
`NowPlayingActivity`, and a foreground `PlaybackService`.
|
||
- **No back button on the now-playing screen** — Android's own is always on
|
||
screen and the panel is tiny (trav's call).
|
||
- **End of a queue pauses.** It does not roll into shuffle-all. Repeat is
|
||
off / all / one.
|
||
- **Replaceable button artwork**: the app loads `Buttons/<name>.png` from the
|
||
device folder when present, else a bundled drawable. LinTunes fills in
|
||
defaults only where the file is *missing*, so trav's own art is never
|
||
overwritten.
|
||
|
||
## What the app reads
|
||
|
||
LinTunes writes all of this; see `lintunes/andtunes/layout.py` for the
|
||
authority and `manifest.py` for the exact rows.
|
||
|
||
```
|
||
/sdcard/Music/andTunes/
|
||
library.json format 1; refuse an unknown format loudly
|
||
Media/<Artist>/<Album>/04 Song.mp3
|
||
Art/<8-hex album key>.jpg 480 px, one per album
|
||
Playlists/<Name>.m3u entries are ../Media/… relative paths
|
||
Buttons/*.png the user's own menu artwork — app reads only
|
||
plays/andtunes-<installid>.json the app writes counts back here (phase 3)
|
||
```
|
||
|
||
```json
|
||
{"format":1,"generated":"…","generator":"lintunes 0.17.0",
|
||
"tracks":[{"id":123,"title":"Alhambra","path":"Media/…/04 Alhambra.mp3",
|
||
"artist":"Ahmad Jamal","album":"…","year":1961,"track":4,
|
||
"secs":214,"art":"Art/3f2a1b9c.jpg"}],
|
||
"playlists":[{"id":"A1B2C3D4","name":"Roadtrip","tracks":[123,456]}]}
|
||
```
|
||
|
||
Rows are **sparse** — empty and zero fields are omitted, so use
|
||
`optString`/`optInt`. Artists and albums are deliberately *not* shipped: group
|
||
the flat list in one pass at load rather than parsing three redundant lists.
|
||
Titles are stored in full even when the filename was truncated, so always
|
||
display `title`, never the filename.
|
||
|
||
## Testing without a Rabbit
|
||
|
||
`python3 scripts/make_dev_library.py --out dev-library` builds a synthetic
|
||
LinTunes library, and syncing it produces a real `Music/andTunes/` tree you can
|
||
push onto any Android device or emulator with `adb push`. That tree includes
|
||
the awkward cases on purpose — an emoji album name, a 208-character title, two
|
||
tracks that differ only by id, a track with no artist.
|
||
|
||
## Board
|
||
|
||
`TASKS.md` in this directory.
|