diff --git a/.gitignore b/.gitignore index 16b8737..18296ea 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ itunes-test-library/ # Synthetic library for development (scripts/make_dev_library.py) /dev-library/ + +# andTunes build output (andtunes/build.py); the shipped APK lives in +# lintunes/android/ and *is* committed +/andtunes/build/ diff --git a/CLAUDE.md b/CLAUDE.md index 922efb6..d0eb360 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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); 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**. + 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 right-click menu). A sibling of `device_sync`, reusing its filename helpers diff --git a/andtunes/README.md b/andtunes/README.md index d40dc3f..3ac3907 100644 --- a/andtunes/README.md +++ b/andtunes/README.md @@ -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 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. +**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). 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 -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 - installed; AGP won't run on it, so the Gradle build must be pointed at 21.) +- `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. -**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. +**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. -```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 +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. -export ANDROID_HOME="$HOME/Android/Sdk" -export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" -export JAVA_HOME=/usr/lib/jvm/java-21-openjdk +`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. -# 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. +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 (once the SDK is there) +## Building and installing ```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 +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 @@ -143,7 +147,8 @@ Target: menu visible in **under 400 ms**. ## 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 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 diff --git a/andtunes/TASKS.md b/andtunes/TASKS.md index cdb0304..a5917b8 100644 --- a/andtunes/TASKS.md +++ b/andtunes/TASKS.md @@ -6,11 +6,10 @@ 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. +**Status: 0.1.0 plays music (2026-09-11, LinTunes round 50).** Built with +`python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see +`README.md`, *Toolchain*). 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.) @@ -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 — trav likes it where it is. - Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free. -- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN** - (`KEYCODE_DPAD_UP` / `DPAD_DOWN`). Side/PTT button sits on `mtk-kpd` with - KEY_VOLUMEDOWN + KEY_POWER. Headset exposes KEY_PLAYPAUSE, so a MediaSession - gets media keys for free. +- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**, + mapped by the stock `Generic.kl` (checked 2026-09-11 via `dumpsys input`) + to **`KEYCODE_DPAD_UP` / `DPAD_DOWN`** — not volume. Side/PTT button sits + 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 - 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`, - R8 minify. Cold start is the whole feature; there is nothing to - initialize if nothing is linked in. -- [ ] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends 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. -- [ ] Library loader: parse `library.json` off the main thread into a - 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 + present; `build.py` points at 21.) +- [x] Android SDK: platform 33 + build-tools 34.0.0 were already in + `~/Android/Sdk`. **No Gradle, no Kotlin** — `build.py` runs aapt2 → + javac → R8 → zipalign → apksigner directly (trav, 2026-09-11: don't + pull a GB over cell if it isn't needed; it isn't). +- [x] Project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33, + framework only: `Activity`, `ListView`, `MediaPlayer`, `MediaSession`, + `Notification.MediaStyle`, R8. The APK is 61 KiB. +- [x] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends the user to + the toggle (LinTunes' installer grants it over adb, so it rarely shows). +- [x] Library loader: `android.util.JsonReader` streaming parse off the main + thread into a process singleton; artists/albums grouped in one pass; + an unknown `format` gets a readable message; reloads when + `library.json` changes (a re-sync) the next time the menu resumes. +- [x] `MenuActivity`: six tiles, 2 × 3, **loads nothing**. The sixth reads "Now Playing" or "Shuffle All" depending on the service. -- [ ] `ListActivity` in songs mode + "Shuffle all" header row. -- [ ] `PlaybackService` (foreground) + `NowPlayingActivity`: white - background, square art, slim scrubber, ◀ ⏯ ▶, shuffle. **No back - button** — Android's own is always on screen. -- [ ] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification. -- [ ] 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] `ListActivity` in songs mode + "Shuffle all" header row. +- [x] `PlaybackService` (foreground) + `NowPlayingActivity`: white + background, square art, slim scrubber, ◀ ⏯ ▶, shuffle, repeat. + **No back button.** +- [x] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification. +- [x] End of queue **pauses** unless repeat is on. Repeat: off / all / one. +- [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), - 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/.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 - artwork is never overwritten. -- [ ] Missing file (synced then deleted) skips with a toast, doesn't crash +- [x] Playlists, artists (→ "All songs" + albums; all songs has its own + shuffle), albums, search (artists, albums and songs as you type). +- [x] Album art thumbs in album rows, full art on now playing. +- [x] The "Now playing" bar at the bottom of every list screen; the playing + song is drawn inverted in any list it appears in. +- [x] Scroll wheel → list scrolling (one row per detent), volume on the menu + and now-playing screens. Taken in `dispatchKeyEvent`, DPAD *and* + VOLUME codes, down *and* up, so the search box can't swallow it. +- [x] Replaceable button images: `Buttons/.png` beats the bundled + drawable, for the six menu tiles plus ◀ ▶ ⏯. LinTunes ships the + defaults into `Buttons/` only where the file is missing. +- [x] Missing file (synced then deleted) skips with a toast, doesn't stop 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 -- [ ] `Connections → Install andTunes on Rabbit…`: `shutil.which("adb")` - (the `ffmpeg_available()` runtime-tool pattern — never a pip dep), then - `adb install -r`; fall back to copying the APK over MTP for a manual - tap. APK committed at `lintunes/android/andTunes.apk` with - `package_data`, read via `importlib.resources` like `export/templates`. +- [x] `Connections → Install andTunes on Rabbit…` (LinTunes 0.19.0): + `adb install -r` + the two permission grants; says Install / Update / + Reinstall from the version on the device; with no adb, copies the APK + to `Download/` over MTP for a manual tap. APK committed at + `lintunes/android/andTunes.apk` (+ `andTunes.json`, its version). - [ ] Format gate shaped like `export/web_support.py`: deny-by-default on - suffix, AIFF and protected AAC refused, anything else unplayable - converted to **FLAC** (never a lossy re-encode). + suffix, protected AAC refused, anything else unplayable converted to + **FLAC** (never a lossy re-encode). - [ ] Play counts back: the app writes `plays/andtunes-.json` in 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 model — no new merge rules. -- [ ] Retire `Sync Playlist to Rabbit (Auxio)` once andTunes covers it, and - offer a one-off cleanup of the old `Music//` folders. +- [ ] Retire `Sync Playlist to Rabbit (Auxio)` now that andTunes covers it. + (trav's R1 has no old `Music//` folders, so no cleanup.) ## Parked diff --git a/andtunes/andtunes.keystore b/andtunes/andtunes.keystore new file mode 100644 index 0000000..1a774c0 Binary files /dev/null and b/andtunes/andtunes.keystore differ diff --git a/andtunes/app/src/main/AndroidManifest.xml b/andtunes/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f2cd6e9 --- /dev/null +++ b/andtunes/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/BaseActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/BaseActivity.java new file mode 100644 index 0000000..c865896 --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/BaseActivity.java @@ -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. + * + *

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); + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/Library.java b/andtunes/app/src/main/java/me/teafry/andtunes/Library.java new file mode 100644 index 0000000..3b4941b --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/Library.java @@ -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 tracks = new ArrayList<>(); + } + + static final class Artist { + String name, sortKey; + final ArrayList albums = new ArrayList<>(); + final ArrayList tracks = new ArrayList<>(); // alphabetical + } + + static final class Playlist { + String id, name; + final ArrayList tracks = new ArrayList<>(); // playing order + } + + interface Listener { void onLibrary(Library library, String error); } + + final ArrayList tracks = new ArrayList<>(); // alphabetical + final ArrayList artists = new ArrayList<>(); + final ArrayList albums = new ArrayList<>(); + final ArrayList playlists = new ArrayList<>(); + final HashMap byId = new HashMap<>(); + final HashMap artistsByName = new HashMap<>(); + final HashMap 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 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 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> 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> ids) + throws IOException { + Playlist p = new Playlist(); + ArrayList 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) + "" + + albumName.toLowerCase(Locale.ROOT); + Album album = albumsByKey.get(key); + if (album == null) { + album = new Album(); + album.key = key; + album.name = albumName; + album.artist = who; + album.sortKey = sortKey(albumName); + albumsByKey.put(key, album); + albums.add(album); + Artist artist = artistsByName.get(who); + if (artist == null) { + artist = new Artist(); + artist.name = who; + artist.sortKey = sortKey(who); + artistsByName.put(who, artist); + artists.add(artist); + } + artist.albums.add(album); + } + album.tracks.add(t); + if (album.art.isEmpty() && !t.art.isEmpty()) album.art = t.art; + if (t.year > 0 && (album.year == 0 || t.year < album.year)) album.year = t.year; + t.albumRef = album; + artistsByName.get(who).tracks.add(t); + } + for (Album a : albums) { + Collections.sort(a.tracks, (x, y) -> x.disc != y.disc ? x.disc - y.disc + : x.number != y.number ? x.number - y.number + : x.sortKey.compareTo(y.sortKey)); + } + Collections.sort(albums, (a, b) -> a.sortKey.compareTo(b.sortKey)); + Collections.sort(artists, (a, b) -> a.sortKey.compareTo(b.sortKey)); + for (Artist a : artists) { + // An artist's own albums read best oldest first. + Collections.sort(a.albums, (x, y) -> x.year != y.year ? x.year - y.year + : x.sortKey.compareTo(y.sortKey)); + } + } + + /** iTunes-style: case-folded, with a leading "The", "A" or "An" ignored. */ + static String sortKey(String s) { + String k = s.trim().toLowerCase(Locale.ROOT); + for (String article : new String[] {"the ", "a ", "an "}) { + if (k.startsWith(article) && k.length() > article.length()) { + k = k.substring(article.length()); + break; + } + } + return k; + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java new file mode 100644 index 0000000..a21f7a0 --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java @@ -0,0 +1,400 @@ +package me.teafry.andtunes; + +import android.content.Intent; +import android.os.Bundle; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.GradientDrawable; +import android.widget.BaseAdapter; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Locale; + +/** + * Every list in the app, parameterised by {@link #MODE}. Screens are separate + * activities so "go back to where I was" is just Android's back stack. + */ +public class ListActivity extends BaseActivity { + static final String MODE = "mode", ID = "id"; + static final String PLAYLISTS = "playlists", PLAYLIST = "playlist", + ARTISTS = "artists", ARTIST = "artist", ARTIST_SONGS = "artist_songs", + ALBUMS = "albums", ALBUM = "album", SONGS = "songs", SEARCH = "search"; + + private static final int SHUFFLE = 0, SONG = 1, PLAYLIST_ROW = 2, ARTIST_ROW = 3, + ALBUM_ROW = 4, ALL_SONGS = 5, HEADER = 6, NOTE = 7; + private static final int SEARCH_CAP = 300; + + private static final class Row { + final int kind; + final String title, sub; + final Object ref; // Track / Playlist / Artist / Album + final int songIndex; // into songs, for SONG rows + Row(int kind, String title, String sub, Object ref, int songIndex) { + this.kind = kind; this.title = title; this.sub = sub; + this.ref = ref; this.songIndex = songIndex; + } + } + + private String mode, id; + private TextView titleBar; + private ListView list; + private EditText searchBox; + private final RowAdapter adapter = new RowAdapter(); + private final ArrayList rows = new ArrayList<>(); + private final ArrayList songs = new ArrayList<>(); + private Library library; + private final Library.Listener onLoaded = (lib, error) -> populate(lib, error); + + @Override protected void onCreate(Bundle state) { + super.onCreate(state); + mode = getIntent().getStringExtra(MODE); + id = getIntent().getStringExtra(ID); + if (mode == null) mode = SONGS; + + LinearLayout column = new LinearLayout(this); + column.setOrientation(LinearLayout.VERTICAL); + + titleBar = Ui.text(this, 26, true, Ui.WHITE); + titleBar.setBackgroundColor(Ui.BLACK); + titleBar.setGravity(Gravity.CENTER_VERTICAL); + int pad = Ui.dp(this, 20); + titleBar.setPadding(pad, 0, pad, 0); + column.addView(titleBar, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 64))); + + if (SEARCH.equals(mode)) column.addView(buildSearchBox()); + + list = new ListView(this); + list.setDivider(new ColorDrawable(Ui.BLACK)); + list.setDividerHeight(Ui.dp(this, 1)); + list.setBackgroundColor(Ui.WHITE); + list.setAdapter(adapter); + list.setOnItemClickListener((parent, view, position, rowId) -> onRow(rows.get(position))); + column.addView(list, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + + setScreen(column, true); + titleBar.setText(defaultTitle()); + rows.add(new Row(NOTE, "Loading…", null, null, -1)); + Library.whenReady(onLoaded); + } + + @Override protected void onDestroy() { + Library.forget(onLoaded); + super.onDestroy(); + } + + @Override public void onPlaybackChanged() { + super.onPlaybackChanged(); + adapter.notifyDataSetChanged(); // re-mark the playing row + } + + @Override protected void onWheel(int dir) { + list.scrollListBy(-dir * Ui.dp(this, Ui.ROW_DP)); + } + + private String defaultTitle() { + switch (mode) { + case PLAYLISTS: return "Playlists"; + case ARTISTS: return "Artists"; + case ALBUMS: return "Albums"; + case SEARCH: return "Search"; + default: return "Songs"; + } + } + + // ---- building the rows ---- + + private void populate(Library lib, String error) { + library = lib; + rows.clear(); + songs.clear(); + if (lib == null) { + rows.add(new Row(NOTE, "No library", error, null, -1)); + adapter.notifyDataSetChanged(); + return; + } + switch (mode) { + case PLAYLISTS: + for (Library.Playlist p : lib.playlists) { + rows.add(new Row(PLAYLIST_ROW, p.name, count(p.tracks.size()), p, -1)); + } + if (lib.playlists.isEmpty()) note("No playlists synced yet"); + break; + case PLAYLIST: { + Library.Playlist p = findPlaylist(lib); + if (p == null) { note("This playlist isn't on the Rabbit anymore"); break; } + titleBar.setText(p.name); + songList(p.tracks, "Shuffle", false); + break; + } + case ARTISTS: + for (Library.Artist a : lib.artists) { + rows.add(new Row(ARTIST_ROW, a.name, count(a.tracks.size()), a, -1)); + } + break; + case ARTIST: { + Library.Artist a = lib.artistsByName.get(id); + if (a == null) { note("Not in the library anymore"); break; } + titleBar.setText(a.name); + rows.add(new Row(ALL_SONGS, "All songs", count(a.tracks.size()), a, -1)); + for (Library.Album al : a.albums) { + rows.add(new Row(ALBUM_ROW, al.name, + al.year > 0 ? String.valueOf(al.year) : count(al.tracks.size()), al, -1)); + } + break; + } + case ARTIST_SONGS: { + Library.Artist a = lib.artistsByName.get(id); + if (a == null) { note("Not in the library anymore"); break; } + titleBar.setText(a.name); + songList(a.tracks, "Shuffle all", false); + break; + } + case ALBUMS: + for (Library.Album al : lib.albums) { + rows.add(new Row(ALBUM_ROW, al.name, al.artist, al, -1)); + } + break; + case ALBUM: { + Library.Album al = lib.albumsByKey.get(id); + if (al == null) { note("Not in the library anymore"); break; } + titleBar.setText(al.name); + songList(al.tracks, "Shuffle", true); + break; + } + case SEARCH: + search(searchBox.getText().toString()); + return; + default: + songList(lib.tracks, "Shuffle all", false); + } + adapter.notifyDataSetChanged(); + } + + private void songList(ArrayList tracks, String shuffleLabel, boolean numbered) { + if (tracks.isEmpty()) { note("No songs"); return; } + rows.add(new Row(SHUFFLE, shuffleLabel, count(tracks.size()), null, -1)); + for (Library.Track t : tracks) addSong(t, numbered); + } + + private void addSong(Library.Track t, boolean numbered) { + String sub; + if (numbered) { + sub = (t.number > 0 ? t.number + " · " : "") + (t.secs > 0 ? Ui.time(t.secs * 1000) : ""); + } else { + sub = t.artistLabel(); + } + rows.add(new Row(SONG, t.title, sub, t, songs.size())); + songs.add(t); + } + + private void note(String text) { rows.add(new Row(NOTE, text, null, null, -1)); } + + private Library.Playlist findPlaylist(Library lib) { + for (Library.Playlist p : lib.playlists) if (p.id.equals(id)) return p; + return null; + } + + private static String count(int n) { return n == 1 ? "1 song" : n + " songs"; } + + // ---- search ---- + + private View buildSearchBox() { + searchBox = new EditText(this); + searchBox.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28); + searchBox.setTextColor(Ui.BLACK); + searchBox.setHintTextColor(Ui.DIM); + searchBox.setHint("Song, artist or album"); + searchBox.setSingleLine(true); + searchBox.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + searchBox.setImeOptions(EditorInfo.IME_ACTION_SEARCH); + GradientDrawable border = new GradientDrawable(); + border.setColor(Ui.WHITE); + border.setStroke(Ui.dp(this, 3), Ui.BLACK); + searchBox.setBackground(border); + int pad = Ui.dp(this, 16); + searchBox.setPadding(pad, pad, pad, pad); + searchBox.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int a, int b, int c) { } + @Override public void onTextChanged(CharSequence s, int a, int b, int c) { } + @Override public void afterTextChanged(Editable s) { + if (library != null) search(s.toString()); + } + }); + searchBox.setOnEditorActionListener((v, action, event) -> { + getSystemService(InputMethodManager.class).hideSoftInputFromWindow(v.getWindowToken(), 0); + return true; + }); + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + searchBox.requestFocus(); + return searchBox; + } + + private void search(String raw) { + rows.clear(); + songs.clear(); + String q = raw.trim().toLowerCase(Locale.ROOT); + if (q.isEmpty()) { + adapter.notifyDataSetChanged(); + return; + } + int before = rows.size(); + rows.add(new Row(HEADER, "Artists", null, null, -1)); + for (Library.Artist a : library.artists) { + if (a.name.toLowerCase(Locale.ROOT).contains(q)) { + rows.add(new Row(ARTIST_ROW, a.name, count(a.tracks.size()), a, -1)); + } + } + if (rows.size() == before + 1) rows.remove(before); + + before = rows.size(); + rows.add(new Row(HEADER, "Albums", null, null, -1)); + for (Library.Album al : library.albums) { + if (al.name.toLowerCase(Locale.ROOT).contains(q)) { + rows.add(new Row(ALBUM_ROW, al.name, al.artist, al, -1)); + } + } + if (rows.size() == before + 1) rows.remove(before); + + before = rows.size(); + rows.add(new Row(HEADER, "Songs", null, null, -1)); + for (Library.Track t : library.tracks) { + if (t.haystack.contains(q)) { + addSong(t, false); + if (songs.size() >= SEARCH_CAP) break; + } + } + if (rows.size() == before + 1) rows.remove(before); + if (rows.isEmpty()) note("Nothing matches “" + raw.trim() + "”"); + adapter.notifyDataSetChanged(); + list.setSelection(0); + } + + // ---- taps ---- + + private void onRow(Row row) { + switch (row.kind) { + case SHUFFLE: + PlaybackService.play(this, songs, -1, true); + openNowPlaying(); + break; + case SONG: + PlaybackService.play(this, songs, row.songIndex, null); + openNowPlaying(); + break; + case PLAYLIST_ROW: + open(PLAYLIST, ((Library.Playlist) row.ref).id); + break; + case ARTIST_ROW: + open(ARTIST, ((Library.Artist) row.ref).name); + break; + case ALL_SONGS: + open(ARTIST_SONGS, ((Library.Artist) row.ref).name); + break; + case ALBUM_ROW: + open(ALBUM, ((Library.Album) row.ref).key); + break; + } + } + + private void open(String newMode, String newId) { + startActivity(new Intent(this, ListActivity.class) + .putExtra(MODE, newMode).putExtra(ID, newId)); + } + + // ---- rows on screen ---- + + private static final class Holder { + ImageView art; + TextView title, sub; + } + + private final class RowAdapter extends BaseAdapter { + @Override public int getCount() { return rows.size(); } + @Override public Object getItem(int i) { return rows.get(i); } + @Override public long getItemId(int i) { return i; } + @Override public boolean isEnabled(int i) { + int k = rows.get(i).kind; + return k != HEADER && k != NOTE; + } + @Override public boolean areAllItemsEnabled() { return false; } + + @Override public View getView(int i, View convert, ViewGroup parent) { + Holder h; + if (convert == null) { + h = new Holder(); + LinearLayout line = new LinearLayout(ListActivity.this); + line.setOrientation(LinearLayout.HORIZONTAL); + line.setGravity(Gravity.CENTER_VERTICAL); + int pad = Ui.dp(ListActivity.this, 16); + line.setPadding(pad, Ui.dp(ListActivity.this, 8), pad, Ui.dp(ListActivity.this, 8)); + + h.art = new ImageView(ListActivity.this); + h.art.setScaleType(ImageView.ScaleType.CENTER_CROP); + int size = Ui.dp(ListActivity.this, 72); + LinearLayout.LayoutParams alp = new LinearLayout.LayoutParams(size, size); + alp.rightMargin = pad; + line.addView(h.art, alp); + + LinearLayout texts = new LinearLayout(ListActivity.this); + texts.setOrientation(LinearLayout.VERTICAL); + h.title = Ui.text(ListActivity.this, Ui.TITLE_SP, false, Ui.BLACK); + h.sub = Ui.text(ListActivity.this, Ui.SUB_SP, false, Ui.DIM); + texts.addView(h.title); + LinearLayout.LayoutParams slp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + slp.topMargin = Ui.dp(ListActivity.this, 4); + texts.addView(h.sub, slp); + line.addView(texts, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + line.setTag(h); + convert = line; + } else { + h = (Holder) convert.getTag(); + } + + Row row = rows.get(i); + boolean header = row.kind == HEADER; + convert.setMinimumHeight(Ui.dp(ListActivity.this, header ? 48 : Ui.ROW_DP)); + h.title.setText(row.title); + h.title.setTypeface(null, row.kind == SHUFFLE || row.kind == ALL_SONGS || header + ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL); + h.title.setTextSize(TypedValue.COMPLEX_UNIT_SP, header ? 20 : Ui.TITLE_SP); + h.title.setSingleLine(row.kind != NOTE); + h.sub.setText(row.sub); + h.sub.setSingleLine(row.kind != NOTE); + h.sub.setVisibility(row.sub == null || row.sub.isEmpty() ? View.GONE : View.VISIBLE); + + if (row.kind == ALBUM_ROW) { + h.art.setVisibility(View.VISIBLE); + Ui.art(h.art, ((Library.Album) row.ref).art, Ui.dp(ListActivity.this, 72)); + } else { + h.art.setVisibility(View.GONE); + h.art.setTag(null); + } + + // The playing song is drawn inverted, so it can be found at a glance. + PlaybackService s = PlaybackService.get(); + boolean playing = row.kind == SONG && s != null && s.current() == row.ref; + convert.setBackgroundColor(playing ? Ui.BLACK : header ? Ui.PLACEHOLDER : Ui.WHITE); + h.title.setTextColor(playing ? Ui.WHITE : Ui.BLACK); + h.sub.setTextColor(playing ? Ui.WHITE : Ui.DIM); + return convert; + } + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/MenuActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/MenuActivity.java new file mode 100644 index 0000000..911edbe --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/MenuActivity.java @@ -0,0 +1,173 @@ +package me.teafry.andtunes; + +import android.Manifest; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.provider.Settings; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** + * The first screen, and the number the project is judged on: six tiles, + * visible in well under 400 ms. It loads nothing — it only starts the + * library parse in the background, so the data is usually ready by the time a + * finger lands on a tile. + */ +public class MenuActivity extends BaseActivity { + private static final String[] MODES = { + ListActivity.PLAYLISTS, ListActivity.ARTISTS, ListActivity.ALBUMS, + ListActivity.SONGS, ListActivity.SEARCH}; + private static final String[] NAMES = {"playlists", "artists", "albums", "songs", "search"}; + private static final String[] LABELS = {"Playlists", "Artists", "Albums", "Songs", "Search"}; + private static final int[] ART = { + R.drawable.playlists, R.drawable.artists, R.drawable.albums, + R.drawable.songs, R.drawable.search}; + + private ImageView sixth; + private Boolean builtWithAccess; + private boolean sixthShowsNowPlaying; + + @Override protected void onCreate(Bundle state) { + super.onCreate(state); + build(); + } + + @Override protected void onResume() { + super.onResume(); + if (builtWithAccess != hasAccess()) build(); + if (hasAccess()) { + Library.preload(); // no-op unless library.json changed + PlaybackService.restore(this); + } + } + + private void build() { + builtWithAccess = hasAccess(); + if (!builtWithAccess) { + setScreen(setupView(), false); + return; + } + Library.preload(); + if (Build.VERSION.SDK_INT >= 33 + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[] {Manifest.permission.POST_NOTIFICATIONS}, 1); + } + + LinearLayout grid = new LinearLayout(this); + grid.setOrientation(LinearLayout.VERTICAL); + int pad = Ui.dp(this, 4); + grid.setPadding(pad, pad, pad, pad); + LinearLayout row = null; + for (int i = 0; i < 6; i++) { + if (i % 2 == 0) { + row = new LinearLayout(this); + grid.addView(row, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + } + ImageView tile = new ImageView(this); + tile.setScaleType(ImageView.ScaleType.FIT_CENTER); + tile.setPadding(pad, pad, pad, pad); + if (i < 5) { + final int n = i; + tile.setImageDrawable(Ui.button(this, NAMES[i], ART[i])); + tile.setContentDescription(LABELS[i]); + tile.setOnClickListener(v -> startActivity( + new Intent(this, ListActivity.class).putExtra(ListActivity.MODE, MODES[n]))); + } else { + sixth = tile; + tile.setOnClickListener(v -> onSixth()); + } + row.addView(tile, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + } + sixthShowsNowPlaying = !hasTrack(); // force the first paint + setScreen(grid, false); + onPlaybackChanged(); + } + + private static boolean hasTrack() { + PlaybackService s = PlaybackService.get(); + return s != null && s.current() != null; + } + + @Override public void onPlaybackChanged() { + super.onPlaybackChanged(); + if (sixth == null) return; + boolean now = hasTrack(); + if (now == sixthShowsNowPlaying) return; + sixthShowsNowPlaying = now; + sixth.setImageDrawable(now ? Ui.button(this, "now_playing", R.drawable.now_playing) + : Ui.button(this, "shuffle_all", R.drawable.shuffle_all)); + sixth.setContentDescription(now ? "Now Playing" : "Shuffle All"); + } + + /** "Now Playing" when there is something, else shuffle everything we know. */ + private void onSixth() { + if (hasTrack()) { + openNowPlaying(); + return; + } + Library.whenReady((lib, error) -> { + if (lib == null || lib.tracks.isEmpty()) { + Ui.toast(this, error != null ? error : "No songs synced yet"); + return; + } + PlaybackService.play(this, lib.tracks, -1, true); + openNowPlaying(); + }); + } + + // ---- first run ---- + + private boolean hasAccess() { + if (Build.VERSION.SDK_INT >= 30) return Environment.isExternalStorageManager(); + return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + } + + private View setupView() { + LinearLayout box = new LinearLayout(this); + box.setOrientation(LinearLayout.VERTICAL); + box.setGravity(Gravity.CENTER); + int pad = Ui.dp(this, 28); + box.setPadding(pad, pad, pad, pad); + + TextView msg = Ui.text(this, 26, false, Ui.BLACK); + msg.setSingleLine(false); + msg.setEllipsize(null); + msg.setText("andTunes plays the music LinTunes puts in Music/andTunes.\n\n" + + "Android needs to let it read that folder: turn on " + + "“Allow access to manage all files”."); + box.addView(msg); + + TextView allow = Ui.text(this, 28, true, Ui.BLACK); + allow.setText("Allow access"); + Ui.toggleStyle(this, allow, true); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 96)); + lp.topMargin = Ui.dp(this, 32); + box.addView(allow, lp); + allow.setOnClickListener(v -> { + if (Build.VERSION.SDK_INT >= 30) { + startActivity(new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:" + getPackageName()))); + } else { + requestPermissions(new String[] {Manifest.permission.READ_EXTERNAL_STORAGE}, 2); + } + }); + return box; + } + + @Override public void onRequestPermissionsResult(int code, String[] perms, int[] results) { + if (code == 2 && builtWithAccess != hasAccess()) build(); + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/NowPlayingActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/NowPlayingActivity.java new file mode 100644 index 0000000..f066824 --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/NowPlayingActivity.java @@ -0,0 +1,207 @@ +package me.teafry.andtunes; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +/** + * White background, square art, a slim scrubber, ◀ ⏯ ▶, then shuffle and + * repeat. No back button — Android's own is always on screen, and the + * panel is too small to spend on a second one (trav's call). The wheel stays + * volume here: this is the one screen where that's the useful thing. + */ +public class NowPlayingActivity extends BaseActivity { + private final Handler main = new Handler(Looper.getMainLooper()); + private ImageView art, playPause; + private TextView title, detail, elapsed, remaining, shuffleBtn, repeatBtn; + private SeekBar scrubber; + private boolean dragging; + private Library.Track shown; + private Drawable playD, pauseD; + + /** An ImageView that stays square inside whatever space it's given. */ + private static final class Square extends ImageView { + Square(Context c) { super(c); } + @Override protected void onMeasure(int w, int h) { + int s = Math.min(android.view.View.MeasureSpec.getSize(w), + android.view.View.MeasureSpec.getSize(h)); + setMeasuredDimension(s, s); + } + } + + @Override protected void onCreate(Bundle state) { + super.onCreate(state); + int pad = Ui.dp(this, 16); + LinearLayout col = new LinearLayout(this); + col.setOrientation(LinearLayout.VERTICAL); + col.setBackgroundColor(Ui.WHITE); + col.setPadding(pad, pad, pad, Ui.dp(this, 8)); + + art = new Square(this); + art.setScaleType(ImageView.ScaleType.CENTER_CROP); + LinearLayout.LayoutParams alp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f); + alp.gravity = Gravity.CENTER_HORIZONTAL; + col.addView(art, alp); + + title = Ui.text(this, 26, true, Ui.BLACK); + title.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams tlp = wrap(); + tlp.topMargin = Ui.dp(this, 12); + col.addView(title, tlp); + detail = Ui.text(this, 20, false, Ui.DIM); + detail.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams dlp = wrap(); + dlp.topMargin = Ui.dp(this, 4); + col.addView(detail, dlp); + + scrubber = new SeekBar(this); + ColorStateList black = ColorStateList.valueOf(Ui.BLACK); + scrubber.setProgressTintList(black); + scrubber.setThumbTintList(black); + scrubber.setProgressBackgroundTintList(ColorStateList.valueOf(0xFF9A9A9A)); + scrubber.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override public void onProgressChanged(SeekBar b, int p, boolean fromUser) { + if (fromUser) showTimes(p, b.getMax()); + } + @Override public void onStartTrackingTouch(SeekBar b) { dragging = true; } + @Override public void onStopTrackingTouch(SeekBar b) { + dragging = false; + PlaybackService s = PlaybackService.get(); + if (s != null) s.seekTo(b.getProgress()); + } + }); + LinearLayout.LayoutParams slp = wrap(); + slp.topMargin = Ui.dp(this, 8); + col.addView(scrubber, slp); + + LinearLayout times = new LinearLayout(this); + elapsed = Ui.text(this, 18, false, Ui.BLACK); + remaining = Ui.text(this, 18, false, Ui.BLACK); + remaining.setGravity(Gravity.END); + times.addView(elapsed, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + times.addView(remaining, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + col.addView(times, wrap()); + + playD = Ui.button(this, "play", R.drawable.play); + pauseD = Ui.button(this, "pause", R.drawable.pause); + LinearLayout transport = new LinearLayout(this); + ImageView prev = transportButton(transport, Ui.button(this, "prev", R.drawable.prev)); + playPause = transportButton(transport, playD); + ImageView next = transportButton(transport, Ui.button(this, "next", R.drawable.next)); + prev.setContentDescription("Previous"); + next.setContentDescription("Next"); + prev.setOnClickListener(v -> withService(PlaybackService::previous)); + playPause.setOnClickListener(v -> withService(PlaybackService::toggle)); + next.setOnClickListener(v -> withService(PlaybackService::next)); + col.addView(transport, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 96))); + + LinearLayout toggles = new LinearLayout(this); + shuffleBtn = toggle(toggles, "Shuffle"); + repeatBtn = toggle(toggles, "Repeat"); + shuffleBtn.setOnClickListener(v -> withService(s -> s.setShuffle(!s.shuffle()))); + repeatBtn.setOnClickListener(v -> withService(PlaybackService::cycleRepeat)); + LinearLayout.LayoutParams glp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 64)); + glp.topMargin = Ui.dp(this, 8); + col.addView(toggles, glp); + + setScreen(col, false); + } + + private LinearLayout.LayoutParams wrap() { + return new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + + private ImageView transportButton(LinearLayout row, Drawable d) { + ImageView b = new ImageView(this); + b.setImageDrawable(d); + b.setScaleType(ImageView.ScaleType.FIT_CENTER); + int p = Ui.dp(this, 12); + b.setPadding(p, p, p, p); + row.addView(b, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + return b; + } + + private TextView toggle(LinearLayout row, String label) { + TextView t = Ui.text(this, 22, true, Ui.BLACK); + t.setText(label); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f); + int m = Ui.dp(this, 6); + lp.setMargins(m, 0, m, 0); + row.addView(t, lp); + return t; + } + + private interface Action { void run(PlaybackService s); } + + private void withService(Action a) { + PlaybackService s = PlaybackService.get(); + if (s != null) a.run(s); + } + + // ---- keeping it current ---- + + private final Runnable ticker = new Runnable() { + @Override public void run() { + PlaybackService s = PlaybackService.get(); + if (s != null && !dragging) { + int dur = s.duration(); + scrubber.setMax(Math.max(dur, 1)); + scrubber.setProgress(s.position()); + showTimes(s.position(), dur); + } + main.postDelayed(this, 500); + } + }; + + @Override protected void onResume() { + super.onResume(); + main.post(ticker); + } + + @Override protected void onPause() { + main.removeCallbacks(ticker); + super.onPause(); + } + + private void showTimes(int pos, int dur) { + elapsed.setText(Ui.time(pos)); + remaining.setText("-" + Ui.time(Math.max(dur - pos, 0))); + } + + @Override public void onPlaybackChanged() { + PlaybackService s = PlaybackService.get(); + Library.Track t = s != null ? s.current() : null; + if (t == null) { + finish(); // nothing to show; back to wherever we came from + return; + } + if (t != shown) { + shown = t; + title.setText(t.title); + String album = t.album.isEmpty() ? "" : " — " + t.album; + detail.setText(t.artistLabel() + album); + Ui.art(art, t.art, 480); + } + playPause.setImageDrawable(s.isPlaying() ? pauseD : playD); + playPause.setContentDescription(s.isPlaying() ? "Pause" : "Play"); + Ui.toggleStyle(this, shuffleBtn, s.shuffle()); + int r = s.repeat(); + repeatBtn.setText(r == PlaybackService.REPEAT_ONE ? "Repeat one" + : r == PlaybackService.REPEAT_ALL ? "Repeat all" : "Repeat"); + Ui.toggleStyle(this, repeatBtn, r != PlaybackService.REPEAT_OFF); + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java b/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java new file mode 100644 index 0000000..bcc8317 --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java @@ -0,0 +1,581 @@ +package me.teafry.andtunes; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.drawable.Icon; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.MediaMetadata; +import android.media.MediaPlayer; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * The queue and the one MediaPlayer, as a foreground service so music keeps + * going with the screen off. + * + *

Activities talk to it directly ({@link #get()}) — it's the same process, + * and a binder would only add ceremony. A new queue arrives through a static + * pending request plus {@code startForegroundService}, because the service may + * not exist yet. + * + *

Rules trav set: the end of a queue pauses (it never rolls into + * shuffle-all), and repeat is off / all / one. + */ +public class PlaybackService extends Service implements + MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener, + MediaPlayer.OnErrorListener, AudioManager.OnAudioFocusChangeListener { + + interface Listener { void onPlaybackChanged(); } + + static final int REPEAT_OFF = 0, REPEAT_ALL = 1, REPEAT_ONE = 2; + + private static final String ACTION_START = "start", ACTION_RESTORE = "restore", + ACTION_TOGGLE = "toggle", ACTION_NEXT = "next", ACTION_PREV = "prev"; + private static final String CHANNEL = "playback"; + private static final int NOTIFICATION_ID = 1; + private static final int RESTART_WINDOW_MS = 3000; + + private static PlaybackService sService; + private static final ArrayList sListeners = new ArrayList<>(); + private static ArrayList sPendingQueue; + private static int sPendingIndex; + private static Boolean sPendingShuffle; + private static boolean sRestoreTried; + + private final Handler main = new Handler(Looper.getMainLooper()); + private final Random random = new Random(); + private MediaPlayer mp; + private MediaSession session; + private AudioManager audio; + private AudioFocusRequest focusRequest; + private NotificationManager notifications; + + private ArrayList queue = new ArrayList<>(); + private int[] order = new int[0]; // play order: indices into queue + private int pos = -1; // index into order + private boolean shuffle; + private int repeat = REPEAT_OFF; + + private boolean prepared, playing, playWhenReady, resumeOnFocus, foreground; + private int pendingSeek; + private int failures; + + // ---- the static front door ---- + + static PlaybackService get() { return sService; } + + static void addListener(Listener l) { if (!sListeners.contains(l)) sListeners.add(l); } + + static void removeListener(Listener l) { sListeners.remove(l); } + + /** + * Play {@code tracks} from {@code index} (-1 = a random start). {@code + * shuffle} null keeps whatever shuffle mode the user left on. + */ + static void play(Context c, List tracks, int index, Boolean shuffle) { + if (tracks.isEmpty()) return; + ArrayList copy = new ArrayList<>(tracks); + if (sService != null) { + sService.setQueue(copy, index, shuffle); + return; + } + sPendingQueue = copy; + sPendingIndex = index; + sPendingShuffle = shuffle; + c.startForegroundService(new Intent(c, PlaybackService.class).setAction(ACTION_START)); + } + + /** Once per process: bring back the last queue, paused where it was left. */ + static void restore(Context c) { + if (sRestoreTried || sService != null) return; + sRestoreTried = true; + if (prefs(c).getString("queue", "").isEmpty()) return; + c.startService(new Intent(c, PlaybackService.class).setAction(ACTION_RESTORE)); + } + + // ---- lifecycle ---- + + @Override public void onCreate() { + super.onCreate(); + sService = this; + sRestoreTried = true; + audio = getSystemService(AudioManager.class); + notifications = getSystemService(NotificationManager.class); + notifications.createNotificationChannel(new NotificationChannel( + CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)); + + AudioAttributes attrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build(); + focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(attrs).setOnAudioFocusChangeListener(this).build(); + + mp = new MediaPlayer(); + mp.setAudioAttributes(attrs); + mp.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK); + mp.setOnPreparedListener(this); + mp.setOnCompletionListener(this); + mp.setOnErrorListener(this); + + session = new MediaSession(this, "andTunes"); + session.setCallback(new MediaSession.Callback() { + @Override public void onPlay() { play(); } + @Override public void onPause() { pause(); } + @Override public void onStop() { pause(); } + @Override public void onSkipToNext() { next(); } + @Override public void onSkipToPrevious() { previous(); } + @Override public void onSeekTo(long ms) { seekTo((int) ms); } + }); + session.setActive(true); + + SharedPreferences p = prefs(this); + shuffle = p.getBoolean("shuffle", false); + repeat = p.getInt("repeat", REPEAT_OFF); + } + + @Override public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent != null ? intent.getAction() : null; + if (ACTION_START.equals(action) && sPendingQueue != null) { + // startForegroundService() gives us five seconds to say so. + ArrayList q = sPendingQueue; + sPendingQueue = null; + setQueue(q, sPendingIndex, sPendingShuffle); + } else if (ACTION_RESTORE.equals(action)) { + restoreState(); + } else if (ACTION_TOGGLE.equals(action)) { + toggle(); + } else if (ACTION_NEXT.equals(action)) { + next(); + } else if (ACTION_PREV.equals(action)) { + previous(); + } + if (ACTION_START.equals(action) && !foreground) goForeground(); + return START_NOT_STICKY; + } + + @Override public void onTaskRemoved(Intent rootIntent) { + if (!playing) stopSelf(); + } + + @Override public void onDestroy() { + saveState(); + abandonFocus(); + unregisterNoisy(); + main.removeCallbacksAndMessages(null); + session.release(); + mp.release(); + notifications.cancel(NOTIFICATION_ID); + sService = null; + changed(); + super.onDestroy(); + } + + @Override public IBinder onBind(Intent intent) { return null; } + + // ---- what the screens read ---- + + Library.Track current() { + return pos >= 0 && pos < order.length ? queue.get(order[pos]) : null; + } + + boolean isPlaying() { return playing; } + + boolean shuffle() { return shuffle; } + + int repeat() { return repeat; } + + int position() { + if (!prepared) return pendingSeek; + try { return mp.getCurrentPosition(); } catch (IllegalStateException e) { return 0; } + } + + int duration() { + if (prepared) { + try { return mp.getDuration(); } catch (IllegalStateException ignored) { } + } + Library.Track t = current(); + return t != null ? t.secs * 1000 : 0; + } + + // ---- transport ---- + + void toggle() { if (playing) pause(); else play(); } + + void play() { + if (current() == null) return; + if (!prepared) { playWhenReady = true; return; } + if (audio.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + return; + } + mp.setVolume(1f, 1f); + mp.start(); + playing = true; + registerNoisy(); + goForeground(); + tick(); + changed(); + } + + void pause() { + playWhenReady = false; + if (playing) { + try { mp.pause(); } catch (IllegalStateException ignored) { } + } + playing = false; + unregisterNoisy(); + if (foreground) { + // Keep the notification (and the session) around while paused, but + // let the system reclaim the service if it has to — state is saved. + stopForeground(false); + foreground = false; + } + saveState(); + changed(); + } + + void seekTo(int ms) { + if (prepared) mp.seekTo(ms); + else pendingSeek = ms; + changed(); + } + + void next() { advance(false); } + + void previous() { + if (position() > RESTART_WINDOW_MS || pos <= 0) { + seekTo(0); + return; + } + load(pos - 1, playing, 0); + } + + void setShuffle(boolean on) { + shuffle = on; + Library.Track cur = current(); + int curIndex = cur != null ? order[pos] : -1; + buildOrder(curIndex); + saveState(); + changed(); + } + + void cycleRepeat() { + repeat = (repeat + 1) % 3; + saveState(); + changed(); + } + + // ---- queue ---- + + private void setQueue(ArrayList tracks, int index, Boolean shuffleMode) { + queue = tracks; + if (shuffleMode != null) shuffle = shuffleMode; + int start = index >= 0 && index < tracks.size() ? index : random.nextInt(tracks.size()); + if (shuffleMode == null && index < 0) start = 0; + buildOrder(start); + failures = 0; + load(pos, true, 0); + } + + /** Rebuild the play order so {@code keepIndex} (a queue index) is current. */ + private void buildOrder(int keepIndex) { + int n = queue.size(); + order = new int[n]; + for (int i = 0; i < n; i++) order[i] = i; + if (shuffle) { + for (int i = n - 1; i > 0; i--) { + int j = random.nextInt(i + 1); + int tmp = order[i]; order[i] = order[j]; order[j] = tmp; + } + if (keepIndex >= 0) { + for (int i = 0; i < n; i++) { + if (order[i] == keepIndex) { order[i] = order[0]; order[0] = keepIndex; break; } + } + pos = 0; + } else { + pos = n > 0 ? 0 : -1; + } + } else { + pos = keepIndex >= 0 ? keepIndex : (n > 0 ? 0 : -1); + } + } + + /** Step forward. {@code auto} = the song finished by itself. */ + private void advance(boolean auto) { + if (order.length == 0) return; + if (auto && repeat == REPEAT_ONE) { + load(pos, true, 0); + return; + } + int n = pos + 1; + if (n >= order.length) { + if (repeat != REPEAT_ALL) { + if (auto) { + // End of the queue pauses, parked at the top of the last song. + playing = false; + pendingSeek = 0; + if (prepared) mp.seekTo(0); + pause(); + } + return; + } + if (shuffle) buildOrder(-1); + n = 0; + } + load(n, auto || playing, 0); + } + + private void load(int newPos, boolean play, int seekMs) { + pos = newPos; + Library.Track t = current(); + if (t == null) return; + prepared = false; + playWhenReady = play; + pendingSeek = seekMs; + try { + mp.reset(); + if (!t.file().isFile()) throw new java.io.FileNotFoundException(t.path); + mp.setDataSource(t.file().getPath()); + mp.prepareAsync(); + } catch (Exception e) { + skipBroken(t, e instanceof java.io.FileNotFoundException ? "missing" : "can't play"); + return; + } + saveState(); + changed(); + } + + /** A synced-then-deleted file, or one MediaPlayer chokes on: skip, don't stop. */ + private void skipBroken(Library.Track t, String why) { + Ui.toast(this, "Skipped “" + t.title + "” (" + why + ")"); + if (++failures >= order.length) { + failures = 0; + playing = false; + playWhenReady = false; + changed(); + return; + } + boolean wasPlaying = playWhenReady; + if (pos + 1 < order.length) load(pos + 1, wasPlaying, 0); + else if (repeat == REPEAT_ALL) load(0, wasPlaying, 0); + else { playing = false; changed(); } + } + + @Override public void onPrepared(MediaPlayer player) { + prepared = true; + failures = 0; + if (pendingSeek > 0) mp.seekTo(pendingSeek); + pendingSeek = 0; + if (playWhenReady) play(); + else changed(); + } + + @Override public void onCompletion(MediaPlayer player) { + if (prepared) advance(true); + } + + @Override public boolean onError(MediaPlayer player, int what, int extra) { + Library.Track t = current(); + prepared = false; + if (t != null) skipBroken(t, "can't play"); + return true; + } + + // ---- audio focus, noisy ---- + + @Override public void onAudioFocusChange(int change) { + switch (change) { + case AudioManager.AUDIOFOCUS_LOSS: + resumeOnFocus = false; + pause(); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + resumeOnFocus = playing; + pause(); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + if (playing) mp.setVolume(0.2f, 0.2f); + break; + case AudioManager.AUDIOFOCUS_GAIN: + mp.setVolume(1f, 1f); + if (resumeOnFocus) { resumeOnFocus = false; play(); } + break; + } + } + + private void abandonFocus() { audio.abandonAudioFocusRequest(focusRequest); } + + private final BroadcastReceiver noisy = new BroadcastReceiver() { + @Override public void onReceive(Context c, Intent i) { pause(); } + }; + private boolean noisyRegistered; + + private void registerNoisy() { + if (noisyRegistered) return; + registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + noisyRegistered = true; + } + + private void unregisterNoisy() { + if (!noisyRegistered) return; + unregisterReceiver(noisy); + noisyRegistered = false; + } + + // ---- session, notification, listeners ---- + + private final Runnable ticker = this::tick; + + /** While playing: keep the session position honest and save now and then. */ + private void tick() { + main.removeCallbacks(ticker); + if (!playing) return; + saveState(); + main.postDelayed(ticker, 15000); + } + + private void changed() { + if (sService == this) updateSession(); + for (Listener l : new ArrayList<>(sListeners)) l.onPlaybackChanged(); + } + + private Library.Track shownInSession; + + private void updateSession() { + Library.Track t = current(); + long actions = PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PAUSE + | PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_SKIP_TO_NEXT + | PlaybackState.ACTION_SKIP_TO_PREVIOUS | PlaybackState.ACTION_SEEK_TO + | PlaybackState.ACTION_STOP; + session.setPlaybackState(new PlaybackState.Builder().setActions(actions) + .setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED, + position(), playing ? 1f : 0f).build()); + if (t == null) return; + if (t != shownInSession) { + shownInSession = t; + session.setMetadata(new MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, t.title) + .putString(MediaMetadata.METADATA_KEY_ARTIST, t.artistLabel()) + .putString(MediaMetadata.METADATA_KEY_ALBUM, t.album) + .putLong(MediaMetadata.METADATA_KEY_DURATION, t.secs * 1000L) + .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, Ui.artNow(t.art, 240)) + .build()); + } + notifications.notify(NOTIFICATION_ID, buildNotification()); + } + + private void goForeground() { + startForeground(NOTIFICATION_ID, buildNotification()); + foreground = true; + } + + private Notification buildNotification() { + Library.Track t = current(); + Intent open = new Intent(this, MenuActivity.class) + .setAction(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); + Notification.Builder b = new Notification.Builder(this, CHANNEL) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(t != null ? t.title : "andTunes") + .setContentText(t != null ? t.artistLabel() : "") + .setContentIntent(PendingIntent.getActivity(this, 0, open, + PendingIntent.FLAG_IMMUTABLE)) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOngoing(playing) + .setShowWhen(false) + .addAction(action(R.drawable.prev, "Previous", ACTION_PREV, 1)) + .addAction(action(playing ? R.drawable.pause : R.drawable.play, + playing ? "Pause" : "Play", ACTION_TOGGLE, 2)) + .addAction(action(R.drawable.next, "Next", ACTION_NEXT, 3)) + .setStyle(new Notification.MediaStyle() + .setMediaSession(session.getSessionToken()) + .setShowActionsInCompactView(0, 1, 2)); + if (t != null) b.setLargeIcon(Ui.artNow(t.art, 240)); + return b.build(); + } + + private Notification.Action action(int icon, String label, String act, int code) { + PendingIntent pi = PendingIntent.getService(this, code, + new Intent(this, PlaybackService.class).setAction(act), + PendingIntent.FLAG_IMMUTABLE); + return new Notification.Action.Builder(Icon.createWithResource(this, icon), label, pi) + .build(); + } + + // ---- remembering where we were ---- + + private static SharedPreferences prefs(Context c) { + return c.getSharedPreferences("playback", MODE_PRIVATE); + } + + private void saveState() { + StringBuilder ids = new StringBuilder(), ord = new StringBuilder(); + for (Library.Track t : queue) ids.append(t.id).append(','); + for (int i : order) ord.append(i).append(','); + prefs(this).edit() + .putString("queue", ids.toString()) + .putString("order", ord.toString()) + .putInt("pos", pos) + .putInt("ms", current() != null ? position() : 0) + .putBoolean("shuffle", shuffle) + .putInt("repeat", repeat) + .apply(); + } + + private void restoreState() { + Library.whenReady((lib, error) -> { + if (lib == null || current() != null) return; + SharedPreferences p = prefs(this); + ArrayList q = new ArrayList<>(); + ArrayList kept = new ArrayList<>(); // old index -> still present + String[] ids = p.getString("queue", "").split(","); + for (int i = 0; i < ids.length; i++) { + if (ids[i].isEmpty()) continue; + Library.Track t = lib.byId.get(Long.parseLong(ids[i])); + if (t != null) { q.add(t); kept.add(i); } + } + if (q.isEmpty()) { stopSelf(); return; } + queue = q; + int[] saved = parseInts(p.getString("order", "")); + int savedPos = p.getInt("pos", 0); + if (kept.size() == ids.length - (ids.length > 0 && ids[ids.length - 1].isEmpty() ? 1 : 0) + && saved.length == q.size() && savedPos >= 0 && savedPos < saved.length) { + order = saved; + pos = savedPos; + } else { + // The library changed under the saved queue; keep it, lose the spot. + buildOrder(0); + } + load(pos, false, p.getInt("ms", 0)); + }); + } + + private static int[] parseInts(String csv) { + ArrayList out = new ArrayList<>(); + for (String s : csv.split(",")) { + if (s.isEmpty()) continue; + try { out.add(Integer.parseInt(s)); } catch (NumberFormatException ignored) { } + } + int[] a = new int[out.size()]; + for (int i = 0; i < a.length; i++) a[i] = out.get(i); + return a; + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/Ui.java b/andtunes/app/src/main/java/me/teafry/andtunes/Ui.java new file mode 100644 index 0000000..488f0dc --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/Ui.java @@ -0,0 +1,152 @@ +package me.teafry.andtunes; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.util.LruCache; +import android.util.TypedValue; +import android.view.Gravity; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.File; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Sizes, colours and the two things every screen loads from the device: + * button artwork and album covers. + * + *

The R1 runs at an override density of 160 on a 2.88" panel, so one dp is + * about half its usual physical size — every size in here is roughly double + * what a phone app would use. Don't "fix" that by touching the density. + */ +final class Ui { + static final int BLACK = 0xFF000000; + static final int WHITE = 0xFFFFFFFF; + static final int DIM = 0xFF3A3A3A; // secondary text, still high contrast + static final int PLACEHOLDER = 0xFFE6E6E6; + + static final int ROW_DP = 88; + static final float TITLE_SP = 28; + static final float SUB_SP = 21; + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static final ExecutorService ART = Executors.newFixedThreadPool(2); + private static final LruCache CACHE = + new LruCache(12 * 1024 * 1024) { + @Override protected int sizeOf(String k, Bitmap b) { return b.getByteCount(); } + }; + + private Ui() {} + + static int dp(Context c, float v) { + return Math.round(v * c.getResources().getDisplayMetrics().density); + } + + static TextView text(Context c, float sp, boolean bold, int color) { + TextView t = new TextView(c); + t.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp); + t.setTextColor(color); + t.setSingleLine(true); + t.setEllipsize(TextUtils.TruncateAt.END); + t.setIncludeFontPadding(false); + if (bold) t.setTypeface(Typeface.DEFAULT_BOLD); + return t; + } + + /** A black-outlined pill; filled black when {@code on}. */ + static void toggleStyle(Context c, TextView t, boolean on) { + GradientDrawable d = new GradientDrawable(); + d.setCornerRadius(dp(c, 12)); + d.setStroke(dp(c, 3), BLACK); + d.setColor(on ? BLACK : WHITE); + t.setBackground(d); + t.setTextColor(on ? WHITE : BLACK); + t.setGravity(Gravity.CENTER); + } + + static void toast(Context c, String msg) { + Toast.makeText(c.getApplicationContext(), msg, Toast.LENGTH_SHORT).show(); + } + + static String time(int ms) { + int s = Math.max(ms, 0) / 1000; + return (s / 60) + ":" + (s % 60 < 10 ? "0" : "") + (s % 60); + } + + /** + * {@code Buttons/.png} from the device when the user put one there, + * else the bundled default. LinTunes only ever fills that folder in where + * a file is missing, so what's there is either ours or theirs — theirs wins. + */ + static Drawable button(Context c, String name, int fallback) { + File custom = new File(Library.root(), "Buttons/" + name + ".png"); + if (custom.isFile()) { + Bitmap b = BitmapFactory.decodeFile(custom.getPath()); + if (b != null) return new android.graphics.drawable.BitmapDrawable(c.getResources(), b); + } + return c.getDrawable(fallback); + } + + /** + * Load {@code Art/.jpg} into {@code view}, decoded off the main thread + * at roughly {@code px} wide. Recycled list rows are handled by tagging the + * view with what it should show and dropping results that arrive late. + */ + static void art(ImageView view, String artRel, int px) { + if (artRel == null || artRel.isEmpty()) { + view.setTag(null); + view.setImageDrawable(null); + view.setBackgroundColor(PLACEHOLDER); + return; + } + final String key = artRel + "@" + px; + view.setTag(key); + Bitmap hit = CACHE.get(key); + if (hit != null) { + view.setImageBitmap(hit); + return; + } + view.setImageDrawable(null); + view.setBackgroundColor(PLACEHOLDER); + ART.execute(() -> { + Bitmap b = decode(new File(Library.root(), artRel), px); + if (b != null) CACHE.put(key, b); + MAIN.post(() -> { + if (key.equals(view.getTag()) && b != null) view.setImageBitmap(b); + }); + }); + } + + private static Bitmap decode(File f, int px) { + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + BitmapFactory.decodeFile(f.getPath(), o); + if (o.outWidth <= 0) return null; + int sample = 1; + while (o.outWidth / (sample * 2) >= px) sample *= 2; + o = new BitmapFactory.Options(); + o.inSampleSize = sample; + return BitmapFactory.decodeFile(f.getPath(), o); + } + + /** Synchronous, for the notification and lock screen; small and cached. */ + static Bitmap artNow(String artRel, int px) { + if (artRel == null || artRel.isEmpty()) return null; + String key = artRel + "@" + px; + Bitmap b = CACHE.get(key); + if (b == null) { + b = decode(new File(Library.root(), artRel), px); + if (b != null) CACHE.put(key, b); + } + return b; + } +} diff --git a/andtunes/app/src/main/res/drawable-nodpi/albums.png b/andtunes/app/src/main/res/drawable-nodpi/albums.png new file mode 100644 index 0000000..1cb5bd0 Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/albums.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/artists.png b/andtunes/app/src/main/res/drawable-nodpi/artists.png new file mode 100644 index 0000000..f264249 Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/artists.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/ic_launcher.png b/andtunes/app/src/main/res/drawable-nodpi/ic_launcher.png new file mode 100644 index 0000000..b8f3649 Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/ic_launcher.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/ic_notify.png b/andtunes/app/src/main/res/drawable-nodpi/ic_notify.png new file mode 100644 index 0000000..6f1b368 Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/ic_notify.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/next.png b/andtunes/app/src/main/res/drawable-nodpi/next.png new file mode 100644 index 0000000..ac0d2f4 Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/next.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/now_playing.png b/andtunes/app/src/main/res/drawable-nodpi/now_playing.png new file mode 100644 index 0000000..b6ab761 Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/now_playing.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/pause.png b/andtunes/app/src/main/res/drawable-nodpi/pause.png new file mode 100644 index 0000000..c01e38d Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/pause.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/play.png b/andtunes/app/src/main/res/drawable-nodpi/play.png new file mode 100644 index 0000000..bd5e80c Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/play.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/playlists.png b/andtunes/app/src/main/res/drawable-nodpi/playlists.png new file mode 100644 index 0000000..348f1de Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/playlists.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/prev.png b/andtunes/app/src/main/res/drawable-nodpi/prev.png new file mode 100644 index 0000000..5fed8db Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/prev.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/search.png b/andtunes/app/src/main/res/drawable-nodpi/search.png new file mode 100644 index 0000000..a3236e6 Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/search.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/shuffle_all.png b/andtunes/app/src/main/res/drawable-nodpi/shuffle_all.png new file mode 100644 index 0000000..173434a Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/shuffle_all.png differ diff --git a/andtunes/app/src/main/res/drawable-nodpi/songs.png b/andtunes/app/src/main/res/drawable-nodpi/songs.png new file mode 100644 index 0000000..1d8d85e Binary files /dev/null and b/andtunes/app/src/main/res/drawable-nodpi/songs.png differ diff --git a/andtunes/app/src/main/res/values/styles.xml b/andtunes/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..4a72832 --- /dev/null +++ b/andtunes/app/src/main/res/values/styles.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/andtunes/build.py b/andtunes/build.py new file mode 100644 index 0000000..dc04c40 --- /dev/null +++ b/andtunes/build.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Build the andTunes APK without Gradle. + +Everything an app with no dependencies needs is already in the Android SDK: +aapt2 packages resources, javac compiles against the platform's android.jar, +R8 shrinks and dexes, zipalign + apksigner finish the file. Gradle and the +Android Gradle Plugin are a ~1 GB download that would do the same five steps +— and andTunes links nothing in, which is the point of it (cold start is the +feature), so there is nothing for a dependency manager to manage. + + python3 andtunes/build.py # -> andtunes/build/andTunes.apk + python3 andtunes/build.py --install # ... then adb install -r + launch + python3 andtunes/build.py --ship # ... then copy into lintunes/android/ + +The source tree is the standard Gradle shape (app/src/main/{java,res, +AndroidManifest.xml}), so a Gradle build could be dropped in later with no +moves. Stdlib only. +""" + +import argparse +import json +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +VERSION_NAME = "0.1.0" +VERSION_CODE = 1 + +PACKAGE = "me.teafry.andtunes" +MIN_SDK = 26 +TARGET_SDK = 33 +BUILD_TOOLS = "34.0.0" + +HERE = Path(__file__).resolve().parent +SRC = HERE / "app" / "src" / "main" +OUT = HERE / "build" +APK_NAME = "andTunes.apk" +SHIP_TO = HERE.parent / "lintunes" / "android" / APK_NAME + +# Committed on purpose: both of trav's machines must sign identically, or +# `adb install -r` refuses an update built on the other one. A sideloaded app +# with no store listing has nothing for this key to protect. +KEYSTORE = HERE / "andtunes.keystore" +KEY_ALIAS = "andtunes" +KEY_PASS = "andtunes" + + +def _sdk() -> Path: + for env in ("ANDROID_HOME", "ANDROID_SDK_ROOT"): + if os.environ.get(env): + return Path(os.environ[env]) + return Path.home() / "Android" / "Sdk" + + +def _java_home() -> Path: + # AGP-era tools want 21; Fedora's default `java` may be newer. + if os.environ.get("JAVA_HOME"): + return Path(os.environ["JAVA_HOME"]) + return Path("/usr/lib/jvm/java-21-openjdk") + + +def run(*cmd): + printable = " ".join(str(c) for c in cmd) + print(f" $ {printable[:160]}{'…' if len(printable) > 160 else ''}") + subprocess.run([str(c) for c in cmd], check=True) + + +def build() -> Path: + sdk = _sdk() + bt = sdk / "build-tools" / BUILD_TOOLS + android_jar = sdk / "platforms" / f"android-{TARGET_SDK}" / "android.jar" + jbin = _java_home() / "bin" + for need in (bt / "aapt2", android_jar, jbin / "javac"): + if not need.exists(): + sys.exit(f"missing {need} — see andtunes/README.md, Toolchain") + + if OUT.exists(): + shutil.rmtree(OUT) + gen, classes, dex = OUT / "gen", OUT / "classes", OUT / "dex" + for d in (gen, classes, dex): + d.mkdir(parents=True) + + print("resources") + run(bt / "aapt2", "compile", "--dir", SRC / "res", "-o", OUT / "res.zip") + run(bt / "aapt2", "link", "-o", OUT / "base.apk", + "-I", android_jar, "--manifest", SRC / "AndroidManifest.xml", + "--java", gen, "--proguard", OUT / "aapt-rules.pro", + "--min-sdk-version", MIN_SDK, "--target-sdk-version", TARGET_SDK, + "--version-code", VERSION_CODE, "--version-name", VERSION_NAME, + "--auto-add-overlay", OUT / "res.zip") + + print("java") + sources = sorted(str(p) for p in (SRC / "java").rglob("*.java")) + sources += sorted(str(p) for p in gen.rglob("*.java")) + run(jbin / "javac", "--release", "11", "-Xlint:-options", "-nowarn", + "-encoding", "UTF-8", "-cp", android_jar, "-d", classes, *sources) + classes_jar = OUT / "classes.jar" + with zipfile.ZipFile(classes_jar, "w") as jar: + for f in classes.rglob("*.class"): + jar.write(f, f.relative_to(classes).as_posix()) + + print("r8") + run(jbin / "java", "-cp", bt / "lib" / "d8.jar", "com.android.tools.r8.R8", + "--release", "--min-api", MIN_SDK, "--lib", android_jar, + "--pg-conf", OUT / "aapt-rules.pro", "--pg-conf", HERE / "proguard.pro", + "--output", dex, classes_jar) + + print("package") + unsigned = OUT / "unsigned.apk" + shutil.copyfile(OUT / "base.apk", unsigned) + with zipfile.ZipFile(unsigned, "a", zipfile.ZIP_DEFLATED) as apk: + apk.write(dex / "classes.dex", "classes.dex") + aligned = OUT / "aligned.apk" + run(bt / "zipalign", "-f", "-p", "4", unsigned, aligned) + final = OUT / APK_NAME + env_java = dict(os.environ, JAVA_HOME=str(_java_home()), + PATH=f"{jbin}:{os.environ.get('PATH', '')}") + print(f" $ apksigner sign … {final.name}") + subprocess.run([str(bt / "apksigner"), "sign", "--ks", str(KEYSTORE), + "--ks-key-alias", KEY_ALIAS, + "--ks-pass", f"pass:{KEY_PASS}", + "--key-pass", f"pass:{KEY_PASS}", + "--out", str(final), str(aligned)], + check=True, env=env_java) + print(f"built {final} ({final.stat().st_size // 1024} KiB, " + f"v{VERSION_NAME} / {VERSION_CODE})") + return final + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--install", action="store_true", + help="adb install -r the result and launch it") + ap.add_argument("--ship", action="store_true", + help="copy the APK into lintunes/android/ for LinTunes") + args = ap.parse_args() + apk = build() + if args.ship: + SHIP_TO.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(apk, SHIP_TO) + # LinTunes can't cheaply read a binary manifest, so the version the + # APK carries rides next to it as plain JSON (lintunes/andtunes/install.py). + SHIP_TO.with_suffix(".json").write_text(json.dumps( + {"version_name": VERSION_NAME, "version_code": VERSION_CODE}) + "\n") + print(f"shipped to {SHIP_TO}") + if args.install: + run("adb", "install", "-r", apk) + run("adb", "shell", "am", "start", "-n", f"{PACKAGE}/.MenuActivity") + + +if __name__ == "__main__": + main() diff --git a/andtunes/proguard.pro b/andtunes/proguard.pro new file mode 100644 index 0000000..b75b99e --- /dev/null +++ b/andtunes/proguard.pro @@ -0,0 +1,9 @@ +# aapt2 --proguard already keeps everything the manifest names (activities, +# the service). This file only adds what R8 can't see from there. + +# Keep real class and method names, so a logcat stack trace reads. +-dontobfuscate + +# Not `-keepattributes SourceFile,LineNumberTable`: R8 8.2 (build-tools 34) +# dies with an internal NPE on javac 21's debug info for an anonymous class. +# Class + method names are enough to find a crash in an app this size. diff --git a/andtunes/tools/make_buttons.py b/andtunes/tools/make_buttons.py new file mode 100644 index 0000000..33537c6 --- /dev/null +++ b/andtunes/tools/make_buttons.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Render andTunes' default button artwork. + +These are the stand-ins trav can replace: LinTunes copies each one into +``Music/andTunes/Buttons/`` on the Rabbit only when that file is missing, and +the app draws ``Buttons/.png`` in preference to its bundled copy. So the +names here are a contract with both halves — change one and the user's own art +for it silently stops being used. + + python3 andtunes/tools/make_buttons.py + +Writes the same PNGs to the app's ``res/drawable-nodpi/`` (bundled fallback) +and to ``lintunes/andtunes/buttons/`` (what sync ships). Black and white only: +the R1's panel is dim and contrast is the only thing that reads on it. +""" + +import os +import sys +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtCore import QPointF, QRectF, Qt # noqa: E402 +from PyQt6.QtGui import ( # noqa: E402 + QColor, QFont, QGuiApplication, QImage, QPainter, QPainterPath, QPolygonF, +) + +HERE = Path(__file__).resolve().parent +RES = HERE.parent / "app" / "src" / "main" / "res" / "drawable-nodpi" +SHIP = HERE.parent.parent / "lintunes" / "andtunes" / "buttons" + +# The menu: a 2 × 3 grid on a 480-dp-wide screen, so ~240 × 208 per tile. +TILE_W, TILE_H = 240, 208 +MENU = { + "playlists": "Playlists", + "artists": "Artists", + "albums": "Albums", + "songs": "Songs", + "search": "Search", + "now_playing": "Now Playing", + "shuffle_all": "Shuffle All", +} +ICON = 128 +TRANSPORT = ("prev", "play", "pause", "next") + + +def _canvas(w, h): + image = QImage(w, h, QImage.Format.Format_ARGB32) + image.fill(Qt.GlobalColor.transparent) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + return image, painter + + +def tile(label: str) -> QImage: + image, p = _canvas(TILE_W, TILE_H) + path = QPainterPath() + path.addRoundedRect(QRectF(4, 4, TILE_W - 8, TILE_H - 8), 20, 20) + p.fillPath(path, QColor("black")) + font = QFont("DejaVu Sans") + font.setPixelSize(40) + font.setBold(True) + p.setFont(font) + p.setPen(QColor("white")) + p.drawText(QRectF(12, 12, TILE_W - 24, TILE_H - 24), + int(Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap), + label) + p.end() + return image + + +def _tri(x0, y0, x1, y1, pointing_right=True) -> QPolygonF: + if pointing_right: + pts = [(x0, y0), (x1, (y0 + y1) / 2), (x0, y1)] + else: + pts = [(x1, y0), (x0, (y0 + y1) / 2), (x1, y1)] + return QPolygonF([QPointF(x, y) for x, y in pts]) + + +def transport(name: str, color="black", size=ICON) -> QImage: + image, p = _canvas(size, size) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QColor(color)) + s = size / 128 + if name == "play": + p.drawPolygon(_tri(34 * s, 20 * s, 110 * s, 108 * s)) + elif name == "pause": + p.drawRect(QRectF(28 * s, 20 * s, 26 * s, 88 * s)) + p.drawRect(QRectF(74 * s, 20 * s, 26 * s, 88 * s)) + elif name == "next": + p.drawPolygon(_tri(18 * s, 28 * s, 86 * s, 100 * s)) + p.drawRect(QRectF(88 * s, 28 * s, 16 * s, 72 * s)) + elif name == "prev": + p.drawPolygon(_tri(42 * s, 28 * s, 110 * s, 100 * s, False)) + p.drawRect(QRectF(24 * s, 28 * s, 16 * s, 72 * s)) + p.end() + return image + + +def launcher() -> QImage: + size = 192 + image, p = _canvas(size, size) + path = QPainterPath() + path.addRoundedRect(QRectF(8, 8, size - 16, size - 16), 36, 36) + p.fillPath(path, QColor("black")) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QColor("white")) + p.drawPolygon(_tri(70, 50, 142, 96)) + p.drawPolygon(QPolygonF([QPointF(70, 96), QPointF(142, 96), + QPointF(70, 142)])) + p.end() + return image + + +def save(image: QImage, name: str, *dirs: Path): + for d in dirs: + d.mkdir(parents=True, exist_ok=True) + if not image.save(str(d / f"{name}.png"), "PNG"): + sys.exit(f"could not write {d / name}.png") + + +def main(): + app = QGuiApplication(sys.argv) # noqa: F841 — must outlive the painting + for name, label in MENU.items(): + save(tile(label), name, RES, SHIP) + for name in TRANSPORT: + save(transport(name), name, RES, SHIP) + save(launcher(), "ic_launcher", RES) + # Status-bar icons must be white-on-transparent; Android tints by alpha. + save(transport("play", "white", 96), "ic_notify", RES) + print(f"wrote {len(MENU) + len(TRANSPORT)} buttons to {RES} and {SHIP}") + + +if __name__ == "__main__": + main() diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 6f9af11..646d6a0 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.18.0" +__version__ = "0.19.0" diff --git a/lintunes/android/__init__.py b/lintunes/android/__init__.py new file mode 100644 index 0000000..e2af142 --- /dev/null +++ b/lintunes/android/__init__.py @@ -0,0 +1,6 @@ +"""The andTunes APK LinTunes installs on the Rabbit (see andtunes/install.py). + +Built by ``andtunes/build.py --ship``, which also writes ``andTunes.json`` +with the version inside. A package only so `importlib.resources` can find +the APK in an installed copy, the way `export/templates` is found. +""" diff --git a/lintunes/android/andTunes.apk b/lintunes/android/andTunes.apk new file mode 100644 index 0000000..82b7954 Binary files /dev/null and b/lintunes/android/andTunes.apk differ diff --git a/lintunes/android/andTunes.json b/lintunes/android/andTunes.json new file mode 100644 index 0000000..b7b1950 --- /dev/null +++ b/lintunes/android/andTunes.json @@ -0,0 +1 @@ +{"version_name": "0.1.0", "version_code": 1} diff --git a/lintunes/andtunes/buttons/albums.png b/lintunes/andtunes/buttons/albums.png new file mode 100644 index 0000000..1cb5bd0 Binary files /dev/null and b/lintunes/andtunes/buttons/albums.png differ diff --git a/lintunes/andtunes/buttons/artists.png b/lintunes/andtunes/buttons/artists.png new file mode 100644 index 0000000..f264249 Binary files /dev/null and b/lintunes/andtunes/buttons/artists.png differ diff --git a/lintunes/andtunes/buttons/next.png b/lintunes/andtunes/buttons/next.png new file mode 100644 index 0000000..ac0d2f4 Binary files /dev/null and b/lintunes/andtunes/buttons/next.png differ diff --git a/lintunes/andtunes/buttons/now_playing.png b/lintunes/andtunes/buttons/now_playing.png new file mode 100644 index 0000000..b6ab761 Binary files /dev/null and b/lintunes/andtunes/buttons/now_playing.png differ diff --git a/lintunes/andtunes/buttons/pause.png b/lintunes/andtunes/buttons/pause.png new file mode 100644 index 0000000..c01e38d Binary files /dev/null and b/lintunes/andtunes/buttons/pause.png differ diff --git a/lintunes/andtunes/buttons/play.png b/lintunes/andtunes/buttons/play.png new file mode 100644 index 0000000..bd5e80c Binary files /dev/null and b/lintunes/andtunes/buttons/play.png differ diff --git a/lintunes/andtunes/buttons/playlists.png b/lintunes/andtunes/buttons/playlists.png new file mode 100644 index 0000000..348f1de Binary files /dev/null and b/lintunes/andtunes/buttons/playlists.png differ diff --git a/lintunes/andtunes/buttons/prev.png b/lintunes/andtunes/buttons/prev.png new file mode 100644 index 0000000..5fed8db Binary files /dev/null and b/lintunes/andtunes/buttons/prev.png differ diff --git a/lintunes/andtunes/buttons/search.png b/lintunes/andtunes/buttons/search.png new file mode 100644 index 0000000..a3236e6 Binary files /dev/null and b/lintunes/andtunes/buttons/search.png differ diff --git a/lintunes/andtunes/buttons/shuffle_all.png b/lintunes/andtunes/buttons/shuffle_all.png new file mode 100644 index 0000000..173434a Binary files /dev/null and b/lintunes/andtunes/buttons/shuffle_all.png differ diff --git a/lintunes/andtunes/buttons/songs.png b/lintunes/andtunes/buttons/songs.png new file mode 100644 index 0000000..1d8d85e Binary files /dev/null and b/lintunes/andtunes/buttons/songs.png differ diff --git a/lintunes/andtunes/install.py b/lintunes/andtunes/install.py new file mode 100644 index 0000000..cdb3a79 --- /dev/null +++ b/lintunes/andtunes/install.py @@ -0,0 +1,180 @@ +"""Put the andTunes app on the Rabbit, from LinTunes. + +The APK is built by ``andtunes/build.py --ship`` and committed at +``lintunes/android/andTunes.apk`` — so the self-updater carries a new app to +the other machine with the same ``git pull`` that carries everything else. + +Two routes, best first: + +* **adb** (a runtime CLI tool, found with `shutil.which` — the + `ffmpeg_available()` pattern, never a pip dependency). ``adb install -r`` + upgrades in place, then all-files access is granted the same way the user + would in Settings. That grant works on the R1's userdebug ROM; if a ROM + refuses it, the app's own first-run screen asks instead, so a failed grant + is not a failed install. +* **MTP copy**, when there's no adb: the APK lands in ``Download/`` on the + device for a tap in the Files app. Never overwrites anything but that one + file. +""" + +import json +import re +import shutil +import subprocess +import threading +from importlib import resources +from pathlib import Path + +from PyQt6.QtCore import QObject, pyqtSignal + +from ..device_sync import CHUNK + +PACKAGE = "me.teafry.andtunes" +APK_NAME = "andTunes.apk" +MANUAL_DIR = "Download" +ADB_TIMEOUT = 120 + + +def adb_path() -> str | None: + return shutil.which("adb") + + +def bundled_apk() -> Path | None: + """The APK this LinTunes carries, or None in a checkout that has none.""" + try: + path = Path(str(resources.files("lintunes.android") / APK_NAME)) + except (ModuleNotFoundError, TypeError): + return None + return path if path.is_file() else None + + +def bundled_version() -> dict: + """``{"version_name", "version_code"}`` shipped beside the APK.""" + apk = bundled_apk() + if apk is None: + return {} + try: + return json.loads(apk.with_suffix(".json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _run(cmd: list, timeout: int = 15) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + + +def rabbit_serial(adb: str) -> str | None: + """The adb serial of a connected Rabbit, or the only device if just one. + + `adb devices -l` names the model (``model:Rabbit_R1``). Anything else + plugged in alongside it is never picked by accident — with no Rabbit + named and more than one device, there is no answer. + """ + try: + out = _run([adb, "devices", "-l"]).stdout + except (OSError, subprocess.SubprocessError): + return None + ready = [] + for line in out.splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[1] == "device": + ready.append((parts[0], line.lower())) + for serial, line in ready: + if "rabbit" in line: + return serial + return ready[0][0] if len(ready) == 1 else None + + +def installed_version(adb: str, serial: str) -> dict | None: + """What the device has installed, or None if andTunes isn't there.""" + try: + out = _run([adb, "-s", serial, "shell", "dumpsys", "package", PACKAGE]).stdout + except (OSError, subprocess.SubprocessError): + return None + code = re.search(r"versionCode=(\d+)", out) + name = re.search(r"versionName=(\S+)", out) + if not code: + return None + return {"version_code": int(code[1]), "version_name": name[1] if name else "?"} + + +def install_commands(adb: str, serial: str, apk: Path) -> list[list[str]]: + """The install, then the two grants the first-run screen would ask for.""" + base = [adb, "-s", serial] + return [ + base + ["install", "-r", str(apk)], + base + ["shell", "appops", "set", "--uid", PACKAGE, + "MANAGE_EXTERNAL_STORAGE", "allow"], + base + ["shell", "pm", "grant", PACKAGE, + "android.permission.POST_NOTIFICATIONS"], + ] + + +def copy_for_manual_install(device, apk: Path) -> Path: + """Copy the APK into ``Download/`` over MTP. Chunked, no copystat — + gvfs-MTP rejects it, same as every other write to the device.""" + dest = Path(device.root) / MANUAL_DIR / APK_NAME + dest.parent.mkdir(parents=True, exist_ok=True) + with open(apk, "rb") as src, open(dest, "wb") as dst: + while chunk := src.read(CHUNK): + dst.write(chunk) + return dest + + +class InstallWorker(QObject): + """Runs the install on a daemon thread; `adb install` takes seconds.""" + + finished = pyqtSignal(dict) + failed = pyqtSignal(str) + + def __init__(self, apk: Path, adb: str | None, serial: str | None, + device=None, parent=None): + super().__init__(parent) + self._apk, self._adb, self._serial, self._device = apk, adb, serial, device + self._busy = False + + def busy(self) -> bool: + return self._busy + + def start(self): + if self._busy: + return + self._busy = True + threading.Thread(target=self._run_guarded, daemon=True).start() + + def _run_guarded(self): + try: + self._run() + finally: + self._busy = False + + def _run(self): + if self._adb and self._serial: + install, *grants = install_commands(self._adb, self._serial, self._apk) + try: + result = _run(install, timeout=ADB_TIMEOUT) + except (OSError, subprocess.SubprocessError) as e: + self.failed.emit(f"adb install didn't finish: {e}") + return + if result.returncode != 0 or "Success" not in result.stdout: + why = (result.stderr or result.stdout).strip().splitlines() + self.failed.emit("adb install failed: " + + (why[-1] if why else f"exit {result.returncode}")) + return + granted = True + for cmd in grants: + try: + granted &= _run(cmd).returncode == 0 + except (OSError, subprocess.SubprocessError): + granted = False + self.finished.emit({"method": "adb", "granted": granted}) + return + if self._device is not None: + try: + dest = copy_for_manual_install(self._device, self._apk) + except OSError as e: + self.failed.emit(f"Couldn't copy the app to the {self._device.name}: {e}") + return + self.finished.emit({"method": "copied", "path": str(dest)}) + return + self.failed.emit("No Rabbit found over adb or USB file transfer.") diff --git a/lintunes/andtunes/sync.py b/lintunes/andtunes/sync.py index 676107d..9afb529 100644 --- a/lintunes/andtunes/sync.py +++ b/lintunes/andtunes/sync.py @@ -22,6 +22,7 @@ import json import os import threading from dataclasses import dataclass, field +from importlib import resources from pathlib import Path from PyQt6.QtCore import QObject, pyqtSignal @@ -31,8 +32,8 @@ from ..device_sync import CHUNK, build_m3u, track_display from . import art as art_mod from . import layout from .layout import ( - MANIFEST_NAME, PRESERVED_DIRS, album_key, art_relpath, assert_inside, - media_relpath, playlist_relpath, + BUTTONS_DIR, MANIFEST_NAME, PRESERVED_DIRS, album_key, art_relpath, + assert_inside, media_relpath, playlist_relpath, ) from .manifest import build_manifest @@ -71,6 +72,20 @@ class AndTunesPlan: bytes_freed: int = 0 +def default_buttons() -> list: + """The bundled button PNGs (rendered by andtunes/tools/make_buttons.py). + + Their names are a contract with the app, which draws + ``Buttons/.png`` in preference to its own copy. + """ + folder = resources.files("lintunes.andtunes") / "buttons" + try: + return sorted((p for p in folder.iterdir() if p.name.endswith(".png")), + key=lambda p: p.name) + except (FileNotFoundError, NotADirectoryError): + return [] + + def _relative_to_playlists(media_rel: str) -> str: """A media path as seen from inside the Playlists/ folder.""" return "../" + media_rel @@ -252,6 +267,7 @@ class AndTunesSyncWorker(QObject): self._copy_media() self._sync_art() self._write_index() + self._ship_buttons() self._prune_empty_dirs() self.finished.emit({ "device": device_name, @@ -401,6 +417,26 @@ class AndTunesSyncWorker(QObject): json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + def _ship_buttons(self): + """Default button artwork, only where the file is missing. + + `Buttons/` is the user's folder: a file already there — ours from an + earlier sync, or their own art — is never overwritten, and nothing in + it is ever deleted (the device scan skips it entirely). One stubborn + file costs a default image, not the sync. + """ + plan = self._plan + folder = assert_inside(plan.root, plan.root / BUTTONS_DIR) + for src in default_buttons(): + dest = folder / src.name + try: + if dest.exists(): + continue + folder.mkdir(parents=True, exist_ok=True) + dest.write_bytes(src.read_bytes()) + except OSError: + continue + def _prune_empty_dirs(self): """Drop folders an artist or album left behind when it went away. diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 6ec563b..fbfa0de 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -11,6 +11,7 @@ from PyQt6.QtGui import QAction, QKeySequence from lintunes import device_sync, mpris, music_folder, tagging, theme, url_import from lintunes.andtunes import layout as andtunes_layout +from lintunes.andtunes import install as andtunes_install from lintunes.andtunes import sync as andtunes_sync from lintunes.art_search import AlbumArtFetcher from lintunes.cast.controller import CastController @@ -162,6 +163,7 @@ class MainWindow(QMainWindow): self.statusBar().addPermanentWidget(self._sync_progress) self._sync_worker = None self._andtunes_worker = None + self._install_worker = None self._sync_device_name = "" # Export shares the status-bar progress widgets with device sync — # only one of the two may run at a time (each guards on the other). @@ -357,6 +359,9 @@ class MainWindow(QMainWindow): self._andtunes_action.setEnabled(False) self._add_action(connections_menu, "Rabbit Sync Settings…", "", self._show_device_sync_settings) + self._install_action = self._add_action( + connections_menu, "Install andTunes on Rabbit…", "", + self._install_andtunes) connections_menu.addSeparator() # The per-playlist sync stays until andTunes can actually play a # song: it is what feeds Auxio, and pulling it in the same round that @@ -406,6 +411,13 @@ class MainWindow(QMainWindow): # Whole-library sync doesn't care which view is open — only that a # device is plugged in and at least one playlist is ticked. self._andtunes_action.setEnabled(not busy and device is not None) + # Enabled whenever there's an APK to offer; the click finds the + # Rabbit (adb or MTP) and says so if it can't — cheaper than asking + # adb on every menu open. + self._install_action.setEnabled( + andtunes_install.bundled_apk() is not None + and not (self._install_worker is not None + and self._install_worker.busy())) if self._cast.is_connected(): self._cast_action.setText( @@ -657,6 +669,75 @@ class MainWindow(QMainWindow): msg += f", {summary['skipped']} skipped (no local file)" self.statusBar().showMessage(msg, 10000) + # ---- connections: install the andTunes app ---- + + def _install_andtunes(self): + if self._install_worker is not None and self._install_worker.busy(): + return + apk = andtunes_install.bundled_apk() + if apk is None: + return + adb = andtunes_install.adb_path() + serial = andtunes_install.rabbit_serial(adb) if adb else None + device = device_sync.find_device() + if serial is None and device is None: + QMessageBox.information( + self, "Rabbit not found", + "Plug the Rabbit in with USB debugging on, or in " + "File transfer mode, then try again.") + return + + ours = andtunes_install.bundled_version() + ours_name = ours.get("version_name", "?") + if serial: + have = andtunes_install.installed_version(adb, serial) + if have is None: + verb = "Install" + detail = f"Install andTunes {ours_name} on the Rabbit?" + elif have["version_code"] < ours.get("version_code", 0): + verb = "Update" + detail = (f"The Rabbit has andTunes {have['version_name']}; " + f"this LinTunes carries {ours_name}. Update it?") + else: + verb = "Reinstall" + detail = (f"The Rabbit already has andTunes " + f"{have['version_name']}. Reinstall {ours_name}?") + else: + verb = "Copy" + detail = ("adb isn't available, so the app will be copied to " + "Download/ on the Rabbit for you to open in the Files " + "app and install by hand. Copy it?") + box = QMessageBox(self) + box.setWindowTitle(f"{verb} andTunes") + box.setText(detail) + go = box.addButton(verb, QMessageBox.ButtonRole.AcceptRole) + box.addButton(QMessageBox.StandardButton.Cancel) + box.setDefaultButton(go) + box.exec() + if box.clickedButton() is not go: + return + + self._install_worker = andtunes_install.InstallWorker( + apk, adb, serial, device, self) + self._install_worker.finished.connect(self._on_install_finished) + self._install_worker.failed.connect( + lambda message: QMessageBox.warning(self, "andTunes", message)) + self.statusBar().showMessage("Installing andTunes on the Rabbit…") + self._install_worker.start() + + def _on_install_finished(self, result): + if result["method"] == "copied": + self.statusBar().clearMessage() + QMessageBox.information( + self, "andTunes copied", + f"Copied to {result['path']}.\n\nOn the Rabbit, open Files → " + "Download → andTunes.apk to install it.") + return + msg = "andTunes is installed on the Rabbit" + if not result.get("granted", True): + msg += " — open it once to allow file access" + self.statusBar().showMessage(msg, 8000) + # ---- export ---- def _refresh_export_action(self): diff --git a/setup.py b/setup.py index e417847..9982a72 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,13 @@ setup( packages=find_packages(), # The web-mix templates (player.js/css, the player sprite GIF and # the placeholder image) must ride along in an installed copy. - package_data={"lintunes.export": ["templates/*"]}, + # The andTunes APK LinTunes installs, and the default button artwork + # sync ships to the device. + package_data={ + "lintunes.export": ["templates/*"], + "lintunes.android": ["andTunes.apk", "andTunes.json"], + "lintunes.andtunes": ["buttons/*.png"], + }, install_requires=[ "PyQt6>=6.8.0", # QAudioBufferOutput (visualizer) needs Qt 6.8+ "mutagen>=1.46", diff --git a/tasks-done.md b/tasks-done.md index dc737aa..bc74b85 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,60 @@ ## Done +### Round 50 (2026-09-11) — andTunes plays music on the Rabbit (v0.19.0) + +The Android half of andTunes had been paused on a 1 GB toolchain download. +It turned out not to need one. + +- [x] **No Gradle, no Kotlin, no download.** Platform 33 + build-tools 34 were + already in `~/Android/Sdk`, and an app that links nothing needs five SDK + steps, not a build system. `andtunes/build.py` runs aapt2 → javac → R8 + → zipalign → apksigner. The APK is 61 KiB. trav's call, after asking + what Kotlin and Gradle even buy: nothing the app needs. +- [x] **andTunes 0.1.0, framework-only Java** (`andtunes/app/`): + - a six-tile menu that loads nothing + - a streaming `library.json` parse on a background thread, grouped into + artists and albums in one pass + - one `ListActivity` for playlists, artists (→ All songs + albums with + art thumbs), albums, songs (Shuffle all first) and search + - a Now playing bar on every list, with the playing song drawn inverted + - Now Playing: white, square art, slim scrubber, ◀ ⏯ ▶, shuffle, repeat + off/all/one, **no back button** + - a foreground `PlaybackService` with audio focus, becoming-noisy, + MediaSession and a MediaStyle notification + - end of queue pauses, missing files skip with a toast, and the last + queue and position come back on launch + Black on white throughout for the dim panel, and every size doubled + for the R1's override density. +- [x] **Cold start 313–338 ms** to the menu, measured with `am start -W` on + the R1 (target < 400; the first launch after install is 551 ms, + because of dex verification). +- [x] **The scroll wheel is DPAD, not volume.** `dumpsys input` shows + `och1970_holl_key` uses the stock `Generic.kl` (KEY_UP/DOWN → + DPAD_UP/DOWN). The app takes both DPAD and VOLUME codes in + `dispatchKeyEvent`, down and up. Lists scroll a row per detent; the + menu and Now Playing get volume. +- [x] **Replaceable buttons.** `andtunes/tools/make_buttons.py` renders the + defaults (menu tiles + ◀ ▶ ⏯). Sync copies them into the device's + `Buttons/` **only where the file is missing**, and the app prefers + whatever is there. +- [x] **Connections ▸ Install andTunes on Rabbit…** (`lintunes/andtunes/ + install.py`): adb install -r, then the all-files and notification + grants. The prompt says Install / Update / Reinstall based on the + version already on the device. With no adb, it copies the APK to + `Download/` over MTP. The APK is committed in `lintunes/android/` and + ships with the self-updater. +- [x] **The R1 now comes up as `mtp,adb`** whenever it's unlocked + (`svc usb setScreenUnlockedFunctions mtp`), so sync's gvfs mount and + adb are both there with no mode switching. +- [x] Found while building: R8 8.2 dies with an internal NPE on javac 21's + debug attributes for an anonymous class, so `proguard.pro` keeps none. +- [x] Verified with the **dev library** only: synced headless to the real + gvfs mount (21 tracks, 5 playlists, 4 covers). Then driven on the R1 + with `input tap`/`keyevent` + `screencap` through menu → songs → shuffle + → now playing, artists → artist → album thumbs, the wheel, search, and + resume after a force-stop. `tests/test_round50.py` covers button + shipping and the installer; the full suite passes. + ### Round 49 (2026-09-10) — Import from URL (v0.18.0) trav's `song` shell helper is `yt-dlp --extract-audio --audio-format mp3`, diff --git a/tests/test_round50.py b/tests/test_round50.py new file mode 100644 index 0000000..1d1b14c --- /dev/null +++ b/tests/test_round50.py @@ -0,0 +1,185 @@ +"""Round 50: the andTunes app ships from LinTunes. + +The Android side is built by ``andtunes/build.py`` and verified on the device; +nothing here runs Java. What LinTunes owns, and what's pinned: + +* sync fills ``Buttons/`` with default artwork **only where a file is + missing** — the user's own art is never overwritten, and that folder is + still never pruned; +* the button names are a contract with the app, so the bundled set must + cover every name it asks for; +* installing picks the Rabbit (never some other device that happens to be + plugged in), treats a refused permission grant as a warning rather than a + failed install, and falls back to an MTP copy when there's no adb; +* the committed APK is found through `importlib.resources` with its version. +""" + +import subprocess + +import pytest + +from lintunes.andtunes import install +from lintunes.andtunes.layout import BUTTONS_DIR +from lintunes.andtunes.sync import ( + AndTunesSyncWorker, default_buttons, plan_andtunes_sync, +) +from lintunes.device_sync import Device +from lintunes.models import Track +from lintunes.models.playlist import Playlist, PlaylistType + +# Every name MenuActivity / NowPlayingActivity load via Ui.button(). +APP_BUTTONS = {"playlists", "artists", "albums", "songs", "search", + "now_playing", "shuffle_all", "prev", "play", "pause", "next"} + + +def _sync(tmp_path, qapp): + audio = tmp_path / "local" / "song.mp3" + audio.parent.mkdir(parents=True, exist_ok=True) + audio.write_bytes(b"x" * 64) + track = Track(track_id=1, name="Song", artist="Band", album="Record", + location=str(audio), total_time=60_000) + playlist = Playlist(name="Mix", persistent_id="AAAA0001", + playlist_type=PlaylistType.REGULAR, track_ids=[1]) + root = tmp_path / "device" / "Music" / "andTunes" + plan = plan_andtunes_sync([playlist], {1: track}, root, copy_art=False) + worker = AndTunesSyncWorker(plan) + failures = [] + worker.failed.connect(failures.append) + worker._run() + assert not failures + return root, plan + + +# ---- buttons ---- + +def test_bundled_buttons_cover_every_name_the_app_asks_for(): + names = {p.name.removesuffix(".png") for p in default_buttons()} + assert APP_BUTTONS <= names + + +def test_sync_ships_default_buttons(tmp_path, qapp): + root, _ = _sync(tmp_path, qapp) + shipped = {p.name for p in (root / BUTTONS_DIR).iterdir()} + assert shipped == {p.name for p in default_buttons()} + bundled = {p.name: p.read_bytes() for p in default_buttons()} + assert (root / BUTTONS_DIR / "songs.png").read_bytes() == bundled["songs.png"] + + +def test_sync_never_overwrites_the_users_button_art(tmp_path, qapp): + root = tmp_path / "device" / "Music" / "andTunes" + mine = root / BUTTONS_DIR / "songs.png" + mine.parent.mkdir(parents=True) + mine.write_bytes(b"trav's own art") + (root / BUTTONS_DIR / "extra.png").write_bytes(b"not ours either") + + _, plan = _sync(tmp_path, qapp) + assert mine.read_bytes() == b"trav's own art" + assert (root / BUTTONS_DIR / "extra.png").exists() + assert not any(rel.startswith(BUTTONS_DIR) for rel in plan.stale) + # A second sync still leaves both alone and doesn't count them as stale. + _, plan = _sync(tmp_path, qapp) + assert mine.read_bytes() == b"trav's own art" + assert not any(rel.startswith(BUTTONS_DIR) for rel in plan.stale) + + +# ---- picking the device ---- + +def _fake_run(monkeypatch, outputs): + calls = [] + + def run(cmd, timeout=15): + calls.append(cmd) + out, code = outputs(cmd) + return subprocess.CompletedProcess(cmd, code, stdout=out, stderr="") + monkeypatch.setattr(install, "_run", run) + return calls + + +def test_rabbit_serial_prefers_the_rabbit(monkeypatch): + listing = ("List of devices attached\n" + "PHONE123 device usb:1-1 product:x model:Pixel_7 device:p\n" + "919109A4 device usb:3-3 product:gsi_r1 model:Rabbit_R1 device:r1\n") + _fake_run(monkeypatch, lambda cmd: (listing, 0)) + assert install.rabbit_serial("adb") == "919109A4" + + +def test_rabbit_serial_never_guesses_between_strangers(monkeypatch): + listing = ("List of devices attached\n" + "A device model:Pixel\nB device model:Other\nC unauthorized\n") + _fake_run(monkeypatch, lambda cmd: (listing, 0)) + assert install.rabbit_serial("adb") is None + one = "List of devices attached\nONLY device model:Whatever\nX offline\n" + _fake_run(monkeypatch, lambda cmd: (one, 0)) + assert install.rabbit_serial("adb") == "ONLY" + + +def test_installed_version_parses_dumpsys(monkeypatch): + dump = "Packages:\n Package [me.teafry.andtunes]\n versionCode=3 minSdk=26\n versionName=0.2.1\n" + _fake_run(monkeypatch, lambda cmd: (dump, 0)) + assert install.installed_version("adb", "S") == { + "version_code": 3, "version_name": "0.2.1"} + _fake_run(monkeypatch, lambda cmd: ("Unable to find package\n", 0)) + assert install.installed_version("adb", "S") is None + + +# ---- installing ---- + +def test_install_runs_install_then_grants(monkeypatch, tmp_path, qapp): + apk = tmp_path / "andTunes.apk" + apk.write_bytes(b"PK") + calls = _fake_run(monkeypatch, lambda cmd: ( + "Performing Streamed Install\nSuccess\n" if "install" in cmd else "", 0)) + worker = install.InstallWorker(apk, "adb", "SER") + results = [] + worker.finished.connect(results.append) + worker._run() + assert results == [{"method": "adb", "granted": True}] + assert calls[0] == ["adb", "-s", "SER", "install", "-r", str(apk)] + assert any("MANAGE_EXTERNAL_STORAGE" in c for c in calls[1]) + + +def test_a_refused_grant_is_not_a_failed_install(monkeypatch, tmp_path, qapp): + apk = tmp_path / "andTunes.apk" + apk.write_bytes(b"PK") + _fake_run(monkeypatch, lambda cmd: ( + ("Success\n", 0) if "install" in cmd else ("Security exception\n", 255))) + worker = install.InstallWorker(apk, "adb", "SER") + results = [] + worker.finished.connect(results.append) + worker._run() + assert results == [{"method": "adb", "granted": False}] + + +def test_install_failure_reports_adbs_reason(monkeypatch, tmp_path, qapp): + apk = tmp_path / "andTunes.apk" + apk.write_bytes(b"PK") + _fake_run(monkeypatch, lambda cmd: ( + "Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures differ]\n", 1)) + worker = install.InstallWorker(apk, "adb", "SER") + failures = [] + worker.failed.connect(failures.append) + worker._run() + assert failures and "signatures differ" in failures[0] + + +def test_without_adb_the_apk_is_copied_to_download(tmp_path, qapp): + apk = tmp_path / "andTunes.apk" + apk.write_bytes(b"PK\x03\x04 apk bytes") + storage = tmp_path / "Internal shared storage" + device = Device("Rabbit R1", storage, storage / "Music") + worker = install.InstallWorker(apk, None, None, device) + results = [] + worker.finished.connect(results.append) + worker._run() + dest = storage / "Download" / "andTunes.apk" + assert dest.read_bytes() == apk.read_bytes() + assert results == [{"method": "copied", "path": str(dest)}] + + +def test_the_committed_apk_is_found_with_its_version(): + apk = install.bundled_apk() + if apk is None: + pytest.skip("no APK shipped in this checkout (andtunes/build.py --ship)") + assert apk.read_bytes()[:2] == b"PK" + version = install.bundled_version() + assert version["version_code"] >= 1 and version["version_name"]