v0.17.1: a library to break
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
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
# 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:
|
||||
|
||||
```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
|
||||
|
||||
Already done on this machine:
|
||||
|
||||
- `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.)
|
||||
- `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.
|
||||
|
||||
```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
|
||||
|
||||
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)
|
||||
|
||||
```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
|
||||
|
||||
# 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 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.
|
||||
+20
-6
@@ -6,6 +6,12 @@ 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.
|
||||
|
||||
## Device facts (measured 2026-09-06, `adb shell wm size` etc.)
|
||||
|
||||
- Panel **480 × 640 px**, physical density 320, **override density 160** — so
|
||||
@@ -20,11 +26,15 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||
KEY_VOLUMEDOWN + KEY_POWER. Headset exposes KEY_PLAYPAUSE, so a MediaSession
|
||||
gets media keys for free.
|
||||
|
||||
## Round 48 — plays a song (v0.1.0)
|
||||
## Phase 1 — plays a song (v0.1.0)
|
||||
|
||||
- [ ] Toolchain: `sudo dnf install java-21-openjdk-devel`, Android
|
||||
commandline-tools + platform 33 + build-tools into `~/Android/Sdk`.
|
||||
(Java 25 is installed but AGP won't run on it.) Gradle via the wrapper.
|
||||
- [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`,
|
||||
@@ -46,13 +56,17 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||
- [ ] End of queue **pauses** unless repeat is on. Repeat: off / all / one.
|
||||
- [ ] Verify with `adb shell am start -W` — target: menu visible < 400 ms.
|
||||
|
||||
## Round 49 — 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
|
||||
@@ -61,7 +75,7 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||
the queue.
|
||||
- [ ] Resume last track + position on launch.
|
||||
|
||||
## Round 50 — install from LinTunes, counts coming back
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user