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:
@@ -21,3 +21,7 @@ itunes-test-library/
|
|||||||
|
|
||||||
# Synthetic library for development (scripts/make_dev_library.py)
|
# Synthetic library for development (scripts/make_dev_library.py)
|
||||||
/dev-library/
|
/dev-library/
|
||||||
|
|
||||||
|
# andTunes build output (andtunes/build.py); the shipped APK lives in
|
||||||
|
# lintunes/android/ and *is* committed
|
||||||
|
/andtunes/build/
|
||||||
|
|||||||
@@ -325,6 +325,24 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
|||||||
`device_sync` (so it rides the Syncthing share and both machines agree);
|
`device_sync` (so it rides the Syncthing share and both machines agree);
|
||||||
keeping it off the `Playlist` keeps it out of the conflict-merge machinery,
|
keeping it off the `Playlist` keeps it out of the conflict-merge machinery,
|
||||||
and **unticking is the only way a playlist comes off the device**.
|
and **unticking is the only way a playlist comes off the device**.
|
||||||
|
Since Round 50 the app exists: `andtunes/app/` is plain Java against the
|
||||||
|
Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 →
|
||||||
|
zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 +
|
||||||
|
build-tools 34 were already on disk and trav didn't want a 1 GB download
|
||||||
|
over cell for tools that make the same APK. `--ship` copies it to
|
||||||
|
`lintunes/android/andTunes.apk` with its version in `andTunes.json`;
|
||||||
|
**re-run `--ship` whenever the app changes**, since the committed APK is
|
||||||
|
what the self-updater carries and what `install.py` installs.
|
||||||
|
`andtunes/andtunes.keystore` is committed on purpose (both machines must
|
||||||
|
sign identically or `adb install -r` refuses the update). `install.py` is
|
||||||
|
Connections → Install andTunes on Rabbit…: adb as a runtime tool, then the
|
||||||
|
all-files + notification grants (a refused grant is not a failed install —
|
||||||
|
the app's first-run screen covers it), or an MTP copy to `Download/` with
|
||||||
|
no adb. Sync copies `buttons/*.png` (rendered by
|
||||||
|
`andtunes/tools/make_buttons.py`) into the device's `Buttons/` **only
|
||||||
|
where the file is missing** — those names are a contract with the app's
|
||||||
|
`Ui.button()`, which prefers the device copy. `proguard.pro` keeps no
|
||||||
|
debug attributes because R8 8.2 NPEs on javac 21's.
|
||||||
|
|
||||||
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
|
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
|
||||||
right-click menu). A sibling of `device_sync`, reusing its filename helpers
|
right-click menu). A sibling of `device_sync`, reusing its filename helpers
|
||||||
|
|||||||
+39
-34
@@ -15,10 +15,12 @@ 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
|
`Music/andTunes/library.json` at sync time and the app parses one file
|
||||||
instead of indexing a filesystem. That is the entire architectural bet.
|
instead of indexing a filesystem. That is the entire architectural bet.
|
||||||
|
|
||||||
**Status (2026-09-10): the desktop half is done and shipped** in LinTunes
|
**Status (2026-09-11): andTunes 0.1.0 plays music on the Rabbit.** The
|
||||||
v0.17.0 (`lintunes/andtunes/`, round 47). The Android app has not been
|
desktop half shipped in LinTunes v0.17.0 (round 47); the app itself landed in
|
||||||
started. Work is paused on a slow connection — see *Toolchain* below for the
|
round 50 (LinTunes v0.19.0), built in plain Java with no Gradle and no
|
||||||
one blocking download.
|
download — see *Toolchain*. Cold start to the menu measures 313–338 ms on the
|
||||||
|
R1 (target < 400). Play counts coming back and the format gate are next
|
||||||
|
(`TASKS.md`, phase 3).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,49 +91,51 @@ screen where the stock behaviour is the useful one.
|
|||||||
|
|
||||||
## Toolchain
|
## Toolchain
|
||||||
|
|
||||||
Already done on this machine:
|
Everything is already on this machine, and **nothing else gets downloaded**:
|
||||||
|
|
||||||
- `java-21-openjdk-devel` — installed, `javac` on PATH. (Java 25 is also
|
- `java-21-openjdk-devel` — `build.py` uses `/usr/lib/jvm/java-21-openjdk`
|
||||||
installed; AGP won't run on it, so the Gradle build must be pointed at 21.)
|
(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.
|
- `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
|
**No Gradle and no Kotlin** (decided 2026-09-11). The original plan was
|
||||||
~1–1.5 GB download** and trav is on a very weak connection.
|
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.
|
||||||
|
|
||||||
```sh
|
To build on a second machine: install `java-21-openjdk-devel`, and copy
|
||||||
# 1. commandline-tools (~150 MB) from developer.android.com/studio
|
`~/Android/Sdk/platforms/android-33` and `build-tools/34.0.0` across (≈ 280 MB,
|
||||||
# (bottom of the page, "Command line tools only", linux zip)
|
no internet needed). `ANDROID_HOME` / `JAVA_HOME` override the default paths.
|
||||||
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"
|
`andtunes.keystore` is committed on purpose: both machines must sign the same
|
||||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
way, or `adb install -r` refuses an update built on the other one. A sideloaded
|
||||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
|
app with no store listing has nothing for the key to protect.
|
||||||
|
|
||||||
# 2. the platform we build against (~150 MB total)
|
R8 gotcha: `-keepattributes SourceFile,LineNumberTable` makes R8 8.2 die with
|
||||||
sdkmanager --licenses
|
an internal NPE on javac 21's debug info for an anonymous class, so
|
||||||
sdkmanager "platforms;android-33" "build-tools;34.0.0"
|
`proguard.pro` keeps none. Stack traces still carry class and method names
|
||||||
```
|
(`-dontobfuscate`).
|
||||||
|
|
||||||
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
|
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.
|
`/usr/local/sbin/adb`; two adb versions on PATH fight over the server port.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Building and installing (once the SDK is there)
|
## Building and installing
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd andtunes
|
python3 andtunes/build.py # -> andtunes/build/andTunes.apk
|
||||||
./gradlew assembleDebug
|
python3 andtunes/build.py --install # ... + adb install -r and launch
|
||||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
python3 andtunes/build.py --ship # ... + copy into lintunes/android/
|
||||||
adb shell am start -n me.teafry.andtunes/.MenuActivity
|
|
||||||
|
# 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:
|
# the number the whole project is judged on:
|
||||||
adb shell am start -W -n me.teafry.andtunes/.MenuActivity | grep TotalTime
|
adb shell am start -W -n me.teafry.andtunes/.MenuActivity | grep TotalTime
|
||||||
@@ -143,7 +147,8 @@ Target: menu visible in **under 400 ms**.
|
|||||||
|
|
||||||
## Design decisions already locked
|
## Design decisions already locked
|
||||||
|
|
||||||
- **Kotlin, no Compose, no androidx, no Media3.** Framework `Activity`,
|
- **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
|
`ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`, R8
|
||||||
minify. Cold start is the feature; there is nothing to initialize if nothing
|
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
|
is linked in. Same instinct as `trash.py` and `sync_identity.py` on the
|
||||||
|
|||||||
+59
-60
@@ -6,11 +6,10 @@ A music player for the Rabbit R1 that never scans anything: LinTunes writes
|
|||||||
|
|
||||||
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||||
|
|
||||||
**Status: paused 2026-09-10** on a weak connection — the Android SDK is a
|
**Status: 0.1.0 plays music (2026-09-11, LinTunes round 50).** Built with
|
||||||
~1–1.5 GB download. Everything needed to resume cold is in `README.md`:
|
`python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see
|
||||||
device facts, toolchain commands, locked design decisions, and the exact
|
`README.md`, *Toolchain*). Phases are andTunes' own; which LinTunes round
|
||||||
shape of the `library.json` LinTunes already writes. Phases are andTunes'
|
each lands in is decided when it starts.
|
||||||
own; which LinTunes round each lands in is decided when it starts.
|
|
||||||
|
|
||||||
## Device facts (measured 2026-09-06, `adb shell wm size` etc.)
|
## 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 —
|
row would be 4.4 mm tall. Do not "fix" this by changing the device density —
|
||||||
trav likes it where it is.
|
trav likes it where it is.
|
||||||
- Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free.
|
- Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free.
|
||||||
- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**
|
- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**,
|
||||||
(`KEYCODE_DPAD_UP` / `DPAD_DOWN`). Side/PTT button sits on `mtk-kpd` with
|
mapped by the stock `Generic.kl` (checked 2026-09-11 via `dumpsys input`)
|
||||||
KEY_VOLUMEDOWN + KEY_POWER. Headset exposes KEY_PLAYPAUSE, so a MediaSession
|
to **`KEYCODE_DPAD_UP` / `DPAD_DOWN`** — not volume. Side/PTT button sits
|
||||||
gets media keys for free.
|
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
|
- [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.)
|
present; `build.py` points at 21.)
|
||||||
- [ ] **Android SDK — the one blocker.** commandline-tools + platform 33 +
|
- [x] Android SDK: platform 33 + build-tools 34.0.0 were already in
|
||||||
build-tools into `~/Android/Sdk`, then a first Gradle build to warm the
|
`~/Android/Sdk`. **No Gradle, no Kotlin** — `build.py` runs aapt2 →
|
||||||
wrapper and AGP caches. ~1–1.5 GB all told; after that
|
javac → R8 → zipalign → apksigner directly (trav, 2026-09-11: don't
|
||||||
`./gradlew --offline assembleDebug` needs no connection. Exact commands
|
pull a GB over cell if it isn't needed; it isn't).
|
||||||
in `README.md`.
|
- [x] Project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33,
|
||||||
- [ ] Gradle project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33.
|
framework only: `Activity`, `ListView`, `MediaPlayer`, `MediaSession`,
|
||||||
**No Compose, no androidx, no Media3** — framework `Activity`,
|
`Notification.MediaStyle`, R8. The APK is 61 KiB.
|
||||||
`ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`,
|
- [x] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends the user to
|
||||||
R8 minify. Cold start is the whole feature; there is nothing to
|
the toggle (LinTunes' installer grants it over adb, so it rarely shows).
|
||||||
initialize if nothing is linked in.
|
- [x] Library loader: `android.util.JsonReader` streaming parse off the main
|
||||||
- [ ] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends the user to
|
thread into a process singleton; artists/albums grouped in one pass;
|
||||||
the toggle. `READ_MEDIA_AUDIO` grants the audio files but **not**
|
an unknown `format` gets a readable message; reloads when
|
||||||
`library.json`, which isn't media — that's the whole reason.
|
`library.json` changes (a re-sync) the next time the menu resumes.
|
||||||
- [ ] Library loader: parse `library.json` off the main thread into a
|
- [x] `MenuActivity`: six tiles, 2 × 3, **loads nothing**. The sixth reads
|
||||||
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
|
|
||||||
"Now Playing" or "Shuffle All" depending on the service.
|
"Now Playing" or "Shuffle All" depending on the service.
|
||||||
- [ ] `ListActivity` in songs mode + "Shuffle all" header row.
|
- [x] `ListActivity` in songs mode + "Shuffle all" header row.
|
||||||
- [ ] `PlaybackService` (foreground) + `NowPlayingActivity`: white
|
- [x] `PlaybackService` (foreground) + `NowPlayingActivity`: white
|
||||||
background, square art, slim scrubber, ◀ ⏯ ▶, shuffle. **No back
|
background, square art, slim scrubber, ◀ ⏯ ▶, shuffle, repeat.
|
||||||
button** — Android's own is always on screen.
|
**No back button.**
|
||||||
- [ ] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification.
|
- [x] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification.
|
||||||
- [ ] End of queue **pauses** unless repeat is on. Repeat: off / all / one.
|
- [x] 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] `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),
|
- [x] Playlists, artists (→ "All songs" + albums; all songs has its own
|
||||||
albums, search.
|
shuffle), albums, search (artists, albums and songs as you type).
|
||||||
- [ ] Album art thumbs in album rows, full art on now playing.
|
- [x] Album art thumbs in album rows, full art on now playing.
|
||||||
- [ ] The "Now playing" bar at the bottom of every list screen.
|
- [x] The "Now playing" bar at the bottom of every list screen; the playing
|
||||||
- [ ] Scroll wheel → list scrolling, and volume on the now-playing screen.
|
song is drawn inverted in any list it appears in.
|
||||||
Consume `KEYCODE_VOLUME_UP/DOWN` **and** `DPAD_UP/DOWN` in both
|
- [x] Scroll wheel → list scrolling (one row per detent), volume on the menu
|
||||||
`onKeyDown` and `onKeyUp` — the ROM's key layout decides which arrives,
|
and now-playing screens. Taken in `dispatchKeyEvent`, DPAD *and*
|
||||||
and claiming them in a focused activity affects no other app. First
|
VOLUME codes, down *and* up, so the search box can't swallow it.
|
||||||
check `/system/usr/keylayout/` on the device; see `README.md`.
|
- [x] Replaceable button images: `Buttons/<name>.png` beats the bundled
|
||||||
- [ ] Replaceable button images: load `Buttons/<name>.png` from the device
|
drawable, for the six menu tiles plus ◀ ▶ ⏯. LinTunes ships the
|
||||||
folder when present, else the bundled drawable. LinTunes ships defaults
|
defaults into `Buttons/` only where the file is missing.
|
||||||
into `Buttons/` only when the file is missing, so the user's own
|
- [x] Missing file (synced then deleted) skips with a toast, doesn't stop
|
||||||
artwork is never overwritten.
|
|
||||||
- [ ] Missing file (synced then deleted) skips with a toast, doesn't crash
|
|
||||||
the queue.
|
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
|
## Phase 3 — install from LinTunes, counts coming back
|
||||||
|
|
||||||
- [ ] `Connections → Install andTunes on Rabbit…`: `shutil.which("adb")`
|
- [x] `Connections → Install andTunes on Rabbit…` (LinTunes 0.19.0):
|
||||||
(the `ffmpeg_available()` runtime-tool pattern — never a pip dep), then
|
`adb install -r` + the two permission grants; says Install / Update /
|
||||||
`adb install -r`; fall back to copying the APK over MTP for a manual
|
Reinstall from the version on the device; with no adb, copies the APK
|
||||||
tap. APK committed at `lintunes/android/andTunes.apk` with
|
to `Download/` over MTP for a manual tap. APK committed at
|
||||||
`package_data`, read via `importlib.resources` like `export/templates`.
|
`lintunes/android/andTunes.apk` (+ `andTunes.json`, its version).
|
||||||
- [ ] Format gate shaped like `export/web_support.py`: deny-by-default on
|
- [ ] Format gate shaped like `export/web_support.py`: deny-by-default on
|
||||||
suffix, AIFF and protected AAC refused, anything else unplayable
|
suffix, protected AAC refused, anything else unplayable converted to
|
||||||
converted to **FLAC** (never a lossy re-encode).
|
**FLAC** (never a lossy re-encode).
|
||||||
- [ ] Play counts back: the app writes `plays/andtunes-<installid>.json` in
|
- [ ] Play counts back: the app writes `plays/andtunes-<installid>.json` in
|
||||||
the per-machine-totals shape of `storage/play_journal.py`; sync copies
|
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
|
it back and folds it in. The R1 becomes one more machine in the Round 38
|
||||||
model — no new merge rules.
|
model — no new merge rules.
|
||||||
- [ ] Retire `Sync Playlist to Rabbit (Auxio)` once andTunes covers it, and
|
- [ ] Retire `Sync Playlist to Rabbit (Auxio)` now that andTunes covers it.
|
||||||
offer a one-off cleanup of the old `Music/<Playlist>/` folders.
|
(trav's R1 has no old `Music/<Playlist>/` folders, so no cleanup.)
|
||||||
|
|
||||||
## Parked
|
## Parked
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="me.teafry.andtunes">
|
||||||
|
|
||||||
|
<!-- library.json isn't media, so READ_MEDIA_AUDIO can't open it: all-files
|
||||||
|
access is the whole reason this app can skip scanning. LinTunes grants
|
||||||
|
it over adb at install; the first-run screen is the fallback. -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="andTunes"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:theme="@style/AndTunes"
|
||||||
|
android:allowBackup="false">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MenuActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity android:name=".ListActivity" android:exported="false"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
|
<activity android:name=".NowPlayingActivity" android:exported="false"
|
||||||
|
android:launchMode="singleTop" />
|
||||||
|
|
||||||
|
<service android:name=".PlaybackService" android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package me.teafry.andtunes;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.KeyEvent;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What every andTunes screen shares: the scroll wheel, and the "Now playing"
|
||||||
|
* bar along the bottom.
|
||||||
|
*
|
||||||
|
* <p>The R1's wheel arrives as DPAD_UP / DPAD_DOWN (its key layout is the stock
|
||||||
|
* Generic.kl), but a ROM could map it to VOLUME_UP / DOWN instead, so both are
|
||||||
|
* claimed. They're taken in {@link #dispatchKeyEvent} — before any focused view
|
||||||
|
* (the search box, a list) can swallow them — and on key-up as well as
|
||||||
|
* key-down, or the system can still act on the release. This only applies
|
||||||
|
* while an andTunes screen is in front; volume behaves normally everywhere else.
|
||||||
|
*/
|
||||||
|
abstract class BaseActivity extends Activity implements PlaybackService.Listener {
|
||||||
|
protected AudioManager audio;
|
||||||
|
private TextView nowBar;
|
||||||
|
|
||||||
|
@Override protected void onCreate(Bundle state) {
|
||||||
|
super.onCreate(state);
|
||||||
|
audio = getSystemService(AudioManager.class);
|
||||||
|
setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lay out {@code content} above a now-playing bar (shown only when useful). */
|
||||||
|
protected void setScreen(View content, boolean withNowBar) {
|
||||||
|
LinearLayout column = new LinearLayout(this);
|
||||||
|
column.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
column.setBackgroundColor(Ui.WHITE);
|
||||||
|
column.addView(content, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||||
|
if (withNowBar) {
|
||||||
|
nowBar = Ui.text(this, 24, true, Ui.WHITE);
|
||||||
|
nowBar.setBackgroundColor(Ui.BLACK);
|
||||||
|
nowBar.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
int pad = Ui.dp(this, 20);
|
||||||
|
nowBar.setPadding(pad, 0, pad, 0);
|
||||||
|
nowBar.setOnClickListener(v -> openNowPlaying());
|
||||||
|
column.addView(nowBar, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 80)));
|
||||||
|
}
|
||||||
|
setContentView(column);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void openNowPlaying() {
|
||||||
|
startActivity(new Intent(this, NowPlayingActivity.class)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
PlaybackService.addListener(this);
|
||||||
|
onPlaybackChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void onPause() {
|
||||||
|
PlaybackService.removeListener(this);
|
||||||
|
super.onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onPlaybackChanged() {
|
||||||
|
if (nowBar == null) return;
|
||||||
|
PlaybackService s = PlaybackService.get();
|
||||||
|
Library.Track t = s != null ? s.current() : null;
|
||||||
|
if (t == null) {
|
||||||
|
nowBar.setVisibility(View.GONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nowBar.setVisibility(View.VISIBLE);
|
||||||
|
nowBar.setText((s.isPlaying() ? "Now playing: " : "Paused: ") + t.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean dispatchKeyEvent(KeyEvent e) {
|
||||||
|
int dir = wheelDirection(e.getKeyCode());
|
||||||
|
if (dir == 0) return super.dispatchKeyEvent(e);
|
||||||
|
if (e.getAction() == KeyEvent.ACTION_DOWN) onWheel(dir);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int wheelDirection(int code) {
|
||||||
|
switch (code) {
|
||||||
|
case KeyEvent.KEYCODE_DPAD_UP:
|
||||||
|
case KeyEvent.KEYCODE_VOLUME_UP:
|
||||||
|
return 1;
|
||||||
|
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||||
|
case KeyEvent.KEYCODE_VOLUME_DOWN:
|
||||||
|
return -1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One wheel detent. Volume unless the screen has something to scroll. */
|
||||||
|
protected void onWheel(int dir) {
|
||||||
|
audio.adjustStreamVolume(AudioManager.STREAM_MUSIC,
|
||||||
|
dir > 0 ? AudioManager.ADJUST_RAISE : AudioManager.ADJUST_LOWER,
|
||||||
|
AudioManager.FLAG_SHOW_UI);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package me.teafry.andtunes;
|
||||||
|
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.JsonReader;
|
||||||
|
import android.util.JsonToken;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything andTunes knows, read from the one file LinTunes wrote.
|
||||||
|
*
|
||||||
|
* This is the whole bet: no MediaStore, no directory walk, no tag reads.
|
||||||
|
* {@code library.json} is parsed with a streaming reader on a background
|
||||||
|
* thread (kicked off by the menu, which itself loads nothing), then artists
|
||||||
|
* and albums are grouped from the flat track list in one pass. The result is
|
||||||
|
* immutable and lives for the life of the process, so every screen after the
|
||||||
|
* first is instant.
|
||||||
|
*/
|
||||||
|
final class Library {
|
||||||
|
static final int FORMAT = 1;
|
||||||
|
static final String UNKNOWN_ARTIST = "Unknown Artist";
|
||||||
|
static final String UNKNOWN_ALBUM = "Unknown Album";
|
||||||
|
|
||||||
|
static final class Track {
|
||||||
|
long id;
|
||||||
|
String title = "", path = "", artist = "", albumArtist = "", album = "", art = "";
|
||||||
|
int year, number, disc, secs;
|
||||||
|
String sortKey, haystack;
|
||||||
|
Album albumRef;
|
||||||
|
|
||||||
|
File file() { return new File(root(), path); }
|
||||||
|
String artistLabel() { return artist.isEmpty() ? UNKNOWN_ARTIST : artist; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Album {
|
||||||
|
String key, name, artist, art = "";
|
||||||
|
int year;
|
||||||
|
String sortKey;
|
||||||
|
final ArrayList<Track> tracks = new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Artist {
|
||||||
|
String name, sortKey;
|
||||||
|
final ArrayList<Album> albums = new ArrayList<>();
|
||||||
|
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Playlist {
|
||||||
|
String id, name;
|
||||||
|
final ArrayList<Track> tracks = new ArrayList<>(); // playing order
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Listener { void onLibrary(Library library, String error); }
|
||||||
|
|
||||||
|
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
|
||||||
|
final ArrayList<Artist> artists = new ArrayList<>();
|
||||||
|
final ArrayList<Album> albums = new ArrayList<>();
|
||||||
|
final ArrayList<Playlist> playlists = new ArrayList<>();
|
||||||
|
final HashMap<Long, Track> byId = new HashMap<>();
|
||||||
|
final HashMap<String, Artist> artistsByName = new HashMap<>();
|
||||||
|
final HashMap<String, Album> albumsByKey = new HashMap<>();
|
||||||
|
long stamp; // library.json mtime + size, to notice a re-sync
|
||||||
|
|
||||||
|
// ---- process-wide instance ----
|
||||||
|
|
||||||
|
private static final Handler MAIN = new Handler(Looper.getMainLooper());
|
||||||
|
private static Library sLibrary;
|
||||||
|
private static String sError;
|
||||||
|
private static boolean sLoading;
|
||||||
|
private static final ArrayList<Listener> sWaiting = new ArrayList<>();
|
||||||
|
|
||||||
|
static File root() {
|
||||||
|
return new File(Environment.getExternalStorageDirectory(), "Music/andTunes");
|
||||||
|
}
|
||||||
|
|
||||||
|
static File manifest() { return new File(root(), "library.json"); }
|
||||||
|
|
||||||
|
static Library get() { return sLibrary; }
|
||||||
|
|
||||||
|
/** Start loading if nothing is loaded yet, or the file changed under us. */
|
||||||
|
static void preload() {
|
||||||
|
if (sLoading) return;
|
||||||
|
if (sLibrary != null && sLibrary.stamp == stampOf(manifest())) return;
|
||||||
|
sLoading = true;
|
||||||
|
new Thread(() -> {
|
||||||
|
Library lib = null;
|
||||||
|
String err = null;
|
||||||
|
try {
|
||||||
|
lib = load(manifest());
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
err = describe(e);
|
||||||
|
}
|
||||||
|
final Library done = lib;
|
||||||
|
final String error = err;
|
||||||
|
MAIN.post(() -> {
|
||||||
|
sLoading = false;
|
||||||
|
if (done != null) { sLibrary = done; sError = null; }
|
||||||
|
else if (sLibrary == null) sError = error;
|
||||||
|
ArrayList<Listener> waiting = new ArrayList<>(sWaiting);
|
||||||
|
sWaiting.clear();
|
||||||
|
for (Listener l : waiting) l.onLibrary(sLibrary, sError);
|
||||||
|
});
|
||||||
|
}, "library-load").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call back on the main thread once a library (or an error) is ready. */
|
||||||
|
static void whenReady(Listener listener) {
|
||||||
|
if (!sLoading && (sLibrary != null || sError != null)) {
|
||||||
|
listener.onLibrary(sLibrary, sError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sWaiting.add(listener);
|
||||||
|
preload();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void forget(Listener listener) { sWaiting.remove(listener); }
|
||||||
|
|
||||||
|
private static long stampOf(File f) { return f.lastModified() * 31 + f.length(); }
|
||||||
|
|
||||||
|
private static String describe(Exception e) {
|
||||||
|
if (e instanceof java.io.FileNotFoundException) {
|
||||||
|
return "No music yet.\n\nIn LinTunes: Connections → Sync to Rabbit.";
|
||||||
|
}
|
||||||
|
String msg = e.getMessage();
|
||||||
|
return msg != null && msg.startsWith("andTunes:") ? msg.substring(9)
|
||||||
|
: "Couldn't read library.json:\n" + e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- parsing ----
|
||||||
|
|
||||||
|
static Library load(File file) throws IOException {
|
||||||
|
Library lib = new Library();
|
||||||
|
lib.stamp = stampOf(file);
|
||||||
|
HashMap<String, List<Long>> playlistIds = new HashMap<>();
|
||||||
|
int format = -1;
|
||||||
|
try (JsonReader r = new JsonReader(new InputStreamReader(
|
||||||
|
new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||||
|
r.beginObject();
|
||||||
|
while (r.hasNext()) {
|
||||||
|
String key = r.nextName();
|
||||||
|
if (key.equals("format")) {
|
||||||
|
format = r.nextInt();
|
||||||
|
checkFormat(format);
|
||||||
|
} else if (key.equals("tracks")) {
|
||||||
|
r.beginArray();
|
||||||
|
while (r.hasNext()) lib.addTrack(readTrack(r));
|
||||||
|
r.endArray();
|
||||||
|
} else if (key.equals("playlists")) {
|
||||||
|
r.beginArray();
|
||||||
|
while (r.hasNext()) lib.playlists.add(readPlaylist(r, playlistIds));
|
||||||
|
r.endArray();
|
||||||
|
} else {
|
||||||
|
r.skipValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.endObject();
|
||||||
|
}
|
||||||
|
checkFormat(format);
|
||||||
|
for (Playlist p : lib.playlists) {
|
||||||
|
for (Long id : playlistIds.get(p.id)) {
|
||||||
|
Track t = lib.byId.get(id);
|
||||||
|
if (t != null) p.tracks.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lib.group();
|
||||||
|
return lib;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void checkFormat(int format) {
|
||||||
|
if (format != FORMAT) {
|
||||||
|
throw new IllegalStateException("andTunes:This library was written in format "
|
||||||
|
+ format + ", and this andTunes only reads format " + FORMAT
|
||||||
|
+ ".\n\nIn LinTunes: Connections → Install andTunes on Rabbit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Track readTrack(JsonReader r) throws IOException {
|
||||||
|
Track t = new Track();
|
||||||
|
r.beginObject();
|
||||||
|
while (r.hasNext()) {
|
||||||
|
String key = r.nextName();
|
||||||
|
if (r.peek() == JsonToken.NULL) { r.skipValue(); continue; }
|
||||||
|
switch (key) {
|
||||||
|
case "id": t.id = r.nextLong(); break;
|
||||||
|
case "title": t.title = r.nextString(); break;
|
||||||
|
case "path": t.path = r.nextString(); break;
|
||||||
|
case "artist": t.artist = r.nextString(); break;
|
||||||
|
case "album_artist": t.albumArtist = r.nextString(); break;
|
||||||
|
case "album": t.album = r.nextString(); break;
|
||||||
|
case "art": t.art = r.nextString(); break;
|
||||||
|
case "year": t.year = r.nextInt(); break;
|
||||||
|
case "track": t.number = r.nextInt(); break;
|
||||||
|
case "disc": t.disc = r.nextInt(); break;
|
||||||
|
case "secs": t.secs = r.nextInt(); break;
|
||||||
|
default: r.skipValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.endObject();
|
||||||
|
if (t.title.isEmpty()) {
|
||||||
|
String name = new File(t.path).getName();
|
||||||
|
int dot = name.lastIndexOf('.');
|
||||||
|
t.title = dot > 0 ? name.substring(0, dot) : name;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Playlist readPlaylist(JsonReader r, HashMap<String, List<Long>> ids)
|
||||||
|
throws IOException {
|
||||||
|
Playlist p = new Playlist();
|
||||||
|
ArrayList<Long> members = new ArrayList<>();
|
||||||
|
r.beginObject();
|
||||||
|
while (r.hasNext()) {
|
||||||
|
String key = r.nextName();
|
||||||
|
switch (key) {
|
||||||
|
case "id": p.id = r.nextString(); break;
|
||||||
|
case "name": p.name = r.nextString(); break;
|
||||||
|
case "tracks":
|
||||||
|
r.beginArray();
|
||||||
|
while (r.hasNext()) members.add(r.nextLong());
|
||||||
|
r.endArray();
|
||||||
|
break;
|
||||||
|
default: r.skipValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.endObject();
|
||||||
|
if (p.id == null) p.id = "p" + ids.size();
|
||||||
|
if (p.name == null) p.name = "Untitled";
|
||||||
|
ids.put(p.id, members);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addTrack(Track t) {
|
||||||
|
t.sortKey = sortKey(t.title);
|
||||||
|
t.haystack = (t.title + "\n" + t.artist + "\n" + t.albumArtist + "\n" + t.album)
|
||||||
|
.toLowerCase(Locale.ROOT);
|
||||||
|
tracks.add(t);
|
||||||
|
byId.put(t.id, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Artists and albums, grouped from the flat list in one pass. */
|
||||||
|
private void group() {
|
||||||
|
Collections.sort(tracks, (a, b) -> a.sortKey.compareTo(b.sortKey));
|
||||||
|
for (Track t : tracks) {
|
||||||
|
// Grouped by album artist when there is one, the way the desktop
|
||||||
|
// files them, so a compilation stays one album under one name.
|
||||||
|
String who = !t.albumArtist.isEmpty() ? t.albumArtist
|
||||||
|
: !t.artist.isEmpty() ? t.artist : UNKNOWN_ARTIST;
|
||||||
|
String albumName = t.album.isEmpty() ? UNKNOWN_ALBUM : t.album;
|
||||||
|
String key = who.toLowerCase(Locale.ROOT) + " | ||||||