Round 47 was verified by loading trav's real 21,531-track library. Nothing was written and nothing was at risk, but it's the wrong habit and he said so. The alternative is better for development anyway: reproducible, in-repo, and full of the awkward cases on purpose rather than by luck. scripts/make_dev_library.py builds a complete synthetic library — data dir and music tree — from ffmpeg sine waves in six containers, with art on some albums and not others, every playlist type (regular, folder, live/non-live/ unsupported/limited+nested smart, system, empty, duplicates), a play journal and a tombstone. ~4 MB, gitignored; the generator is the artifact worth keeping, not the sine waves. The edge cases are the point. A collision pair identical in artist/album/title/ number, slashes and colons in every name, a track with no artist, a multi-disc release, a compilation whose album_artist differs, a dangling location, a 208-character title, and non-ASCII plus an emoji all the way out to the m3u filename. CLAUDE.md now carries the rule: never develop against the real library, and when a feature needs a shape the fixture lacks, add it here. Using it found two bugs in it — a relative --out made every location resolve against the wrong root, and four-minute uncompressed clips made it 62 MB. andTunes is paused on a weak connection (the Android SDK is a ~1-1.5 GB one-time download; after that --offline builds need nothing). andtunes/ README.md now carries everything needed to resume cold: measured device facts, the dp trap, exact toolchain commands, locked design decisions, and the library.json contract. JDK 21 is installed and ticked off. Also answers the scroll wheel question: it reads as volume because the ROM's key layout maps the wheel's KEY_UP/KEY_DOWN to KEYCODE_VOLUME_UP/DOWN, but a focused activity sees key events first — so andTunes can claim it by consuming both those and DPAD_UP/DOWN, in onKeyDown and onKeyUp. No system file touched, no other app affected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
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-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.
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:
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:
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
Already done on this machine:
java-21-openjdk-devel— installed,javacon PATH. (Java 25 is also installed; AGP won't run on it, so the Gradle build must be pointed at 21.)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.
# 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
export ANDROID_HOME="$HOME/Android/Sdk"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
# 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.
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)
cd andtunes
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n me.teafry.andtunes/.MenuActivity
# 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
- Kotlin, no Compose, no androidx, no Media3. 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 astrash.pyandsync_identity.pyon the LinTunes side. - Package
me.teafry.andtunes, minSdk 26, targetSdk 33, arm64-v8a. MANAGE_EXTERNAL_STORAGEwith a first-run screen sending the user to the toggle.READ_MEDIA_AUDIOgrants the audio files but notlibrary.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 genericListActivityparameterised for every list in the spec,NowPlayingActivity, and a foregroundPlaybackService. - 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>.pngfrom 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)
{"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.