Files
lintunes/andtunes
travandClaude Opus 5 efa07b05c6 v0.20.4: the song that wasn't there
trav couldn't play the first track of his playlist. Shuffle off gave him song
two; shuffle on gave him a random one. The player was right both times:
library.json named a song whose file was not on the device, and skipping an
unplayable file advances one slot. Tapping any song that *is* there plays
exactly that song, shuffle or not.

The file was missing because a gvfs-MTP mount can hold a phantom directory --
one it lists happily while the device has no such folder. Every write into it
fails EIO, and mkdir(exist_ok=True) sees the phantom and does nothing, so it
never heals; only remounting clears it. That folder was new because Round 52's
retag moved the file under a new artist.

So a copy that raises OSError now costs that song, not the sync, and the song
is left out of the m3u and library.json. Aborting cost 2,400 songs for one
folder; counting it present anyway put a song in the manifest that isn't on
the device, which is the bug you could hear. The names come back in the
summary and a dialog rather than vanishing.

Planning is a worker now. plan_andtunes_sync is pure and writes nothing, but
it walks every file on the device, and over MTP that is thousands of round
trips -- on the GUI thread it froze the window and GNOME offered to kill
LinTunes, which is how a sync got force-quit halfway through.

And the sync stopped going quiet at the end. "Album art 501/501" is emitted
before the last album, and then _write_index, _ship_buttons and
_prune_empty_dirs ran silently: 501 exists() stats over MTP, ~840 KB of
writes and a full tree walk, with the progress line frozen. They report now,
and _write_index reads Art/ in one listing instead of a stat per album.

andTunes 0.2.2: the wheel scrolls the other way in lists, and smoothly --
a detent adds to a pixel debt that a Choreographer callback pays off a
fraction per frame, so one detent eases to a stop and a fast spin blends into
one movement instead of teleporting a row at a time. Volume is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
2026-09-14 13:25:15 -07:00
..
2026-09-14 13:25:15 -07:00
2026-09-14 13:25:15 -07:00

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:

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

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

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)
{"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.