v0.19.0: andTunes plays music on the Rabbit
The Android half of andTunes had been paused on a 1 GB toolchain download (Gradle wrapper, Android Gradle Plugin, Kotlin). None of that was needed: platform 33 and build-tools 34 were already in ~/Android/Sdk, and an app that links no libraries needs five SDK steps, not a build system. andtunes/build.py runs aapt2 -> javac -> R8 -> zipalign -> apksigner and produces a 61 KiB APK. andTunes 0.1.0 is framework-only Java: a six-tile menu that loads nothing, a streaming library.json parse grouped into artists and albums in one pass, one ListActivity for every list (songs and artist songs open with Shuffle all, albums carry art thumbs, search as you type), a Now playing bar under every list, and a white Now Playing screen with square art, a slim scrubber, prev/play/next, shuffle and repeat, and no back button. A foreground PlaybackService owns the queue: audio focus, becoming-noisy, MediaSession and a MediaStyle notification, end of queue pauses, missing files skip, and the last queue and position come back on launch. Black on white, and every size doubled for the R1's override density. Cold start to the menu: 313-338 ms. The scroll wheel turned out to be DPAD_UP/DOWN (stock Generic.kl), not volume. The app takes both in dispatchKeyEvent: lists scroll a row per detent, and the menu and Now Playing get volume. LinTunes side: Connections > Install andTunes on Rabbit... installs the committed APK over adb and grants all-files access, or copies it to Download/ over MTP when there's no adb. Sync fills Buttons/ with default artwork only where a file is missing, so trav's own art is never overwritten. Verified against the dev library only, synced to the R1 and driven with adb input + screencap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="me.teafry.andtunes">
|
||||
|
||||
<!-- library.json isn't media, so READ_MEDIA_AUDIO can't open it: all-files
|
||||
access is the whole reason this app can skip scanning. LinTunes grants
|
||||
it over adb at install; the first-run screen is the fallback. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:label="andTunes"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:theme="@style/AndTunes"
|
||||
android:allowBackup="false">
|
||||
|
||||
<activity
|
||||
android:name=".MenuActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".ListActivity" android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity android:name=".NowPlayingActivity" android:exported="false"
|
||||
android:launchMode="singleTop" />
|
||||
|
||||
<service android:name=".PlaybackService" android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,109 @@
|
||||
package me.teafry.andtunes;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Bundle;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
/**
|
||||
* What every andTunes screen shares: the scroll wheel, and the "Now playing"
|
||||
* bar along the bottom.
|
||||
*
|
||||
* <p>The R1's wheel arrives as DPAD_UP / DPAD_DOWN (its key layout is the stock
|
||||
* Generic.kl), but a ROM could map it to VOLUME_UP / DOWN instead, so both are
|
||||
* claimed. They're taken in {@link #dispatchKeyEvent} — before any focused view
|
||||
* (the search box, a list) can swallow them — and on key-up as well as
|
||||
* key-down, or the system can still act on the release. This only applies
|
||||
* while an andTunes screen is in front; volume behaves normally everywhere else.
|
||||
*/
|
||||
abstract class BaseActivity extends Activity implements PlaybackService.Listener {
|
||||
protected AudioManager audio;
|
||||
private TextView nowBar;
|
||||
|
||||
@Override protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
audio = getSystemService(AudioManager.class);
|
||||
setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||
}
|
||||
|
||||
/** Lay out {@code content} above a now-playing bar (shown only when useful). */
|
||||
protected void setScreen(View content, boolean withNowBar) {
|
||||
LinearLayout column = new LinearLayout(this);
|
||||
column.setOrientation(LinearLayout.VERTICAL);
|
||||
column.setBackgroundColor(Ui.WHITE);
|
||||
column.addView(content, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
if (withNowBar) {
|
||||
nowBar = Ui.text(this, 24, true, Ui.WHITE);
|
||||
nowBar.setBackgroundColor(Ui.BLACK);
|
||||
nowBar.setGravity(Gravity.CENTER_VERTICAL);
|
||||
int pad = Ui.dp(this, 20);
|
||||
nowBar.setPadding(pad, 0, pad, 0);
|
||||
nowBar.setOnClickListener(v -> openNowPlaying());
|
||||
column.addView(nowBar, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 80)));
|
||||
}
|
||||
setContentView(column);
|
||||
}
|
||||
|
||||
protected void openNowPlaying() {
|
||||
startActivity(new Intent(this, NowPlayingActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT));
|
||||
}
|
||||
|
||||
@Override protected void onResume() {
|
||||
super.onResume();
|
||||
PlaybackService.addListener(this);
|
||||
onPlaybackChanged();
|
||||
}
|
||||
|
||||
@Override protected void onPause() {
|
||||
PlaybackService.removeListener(this);
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override public void onPlaybackChanged() {
|
||||
if (nowBar == null) return;
|
||||
PlaybackService s = PlaybackService.get();
|
||||
Library.Track t = s != null ? s.current() : null;
|
||||
if (t == null) {
|
||||
nowBar.setVisibility(View.GONE);
|
||||
return;
|
||||
}
|
||||
nowBar.setVisibility(View.VISIBLE);
|
||||
nowBar.setText((s.isPlaying() ? "Now playing: " : "Paused: ") + t.title);
|
||||
}
|
||||
|
||||
@Override public boolean dispatchKeyEvent(KeyEvent e) {
|
||||
int dir = wheelDirection(e.getKeyCode());
|
||||
if (dir == 0) return super.dispatchKeyEvent(e);
|
||||
if (e.getAction() == KeyEvent.ACTION_DOWN) onWheel(dir);
|
||||
return true;
|
||||
}
|
||||
|
||||
static int wheelDirection(int code) {
|
||||
switch (code) {
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_VOLUME_UP:
|
||||
return 1;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_VOLUME_DOWN:
|
||||
return -1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** One wheel detent. Volume unless the screen has something to scroll. */
|
||||
protected void onWheel(int dir) {
|
||||
audio.adjustStreamVolume(AudioManager.STREAM_MUSIC,
|
||||
dir > 0 ? AudioManager.ADJUST_RAISE : AudioManager.ADJUST_LOWER,
|
||||
AudioManager.FLAG_SHOW_UI);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package me.teafry.andtunes;
|
||||
|
||||
import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.JsonReader;
|
||||
import android.util.JsonToken;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Everything andTunes knows, read from the one file LinTunes wrote.
|
||||
*
|
||||
* This is the whole bet: no MediaStore, no directory walk, no tag reads.
|
||||
* {@code library.json} is parsed with a streaming reader on a background
|
||||
* thread (kicked off by the menu, which itself loads nothing), then artists
|
||||
* and albums are grouped from the flat track list in one pass. The result is
|
||||
* immutable and lives for the life of the process, so every screen after the
|
||||
* first is instant.
|
||||
*/
|
||||
final class Library {
|
||||
static final int FORMAT = 1;
|
||||
static final String UNKNOWN_ARTIST = "Unknown Artist";
|
||||
static final String UNKNOWN_ALBUM = "Unknown Album";
|
||||
|
||||
static final class Track {
|
||||
long id;
|
||||
String title = "", path = "", artist = "", albumArtist = "", album = "", art = "";
|
||||
int year, number, disc, secs;
|
||||
String sortKey, haystack;
|
||||
Album albumRef;
|
||||
|
||||
File file() { return new File(root(), path); }
|
||||
String artistLabel() { return artist.isEmpty() ? UNKNOWN_ARTIST : artist; }
|
||||
}
|
||||
|
||||
static final class Album {
|
||||
String key, name, artist, art = "";
|
||||
int year;
|
||||
String sortKey;
|
||||
final ArrayList<Track> tracks = new ArrayList<>();
|
||||
}
|
||||
|
||||
static final class Artist {
|
||||
String name, sortKey;
|
||||
final ArrayList<Album> albums = new ArrayList<>();
|
||||
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
|
||||
}
|
||||
|
||||
static final class Playlist {
|
||||
String id, name;
|
||||
final ArrayList<Track> tracks = new ArrayList<>(); // playing order
|
||||
}
|
||||
|
||||
interface Listener { void onLibrary(Library library, String error); }
|
||||
|
||||
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
|
||||
final ArrayList<Artist> artists = new ArrayList<>();
|
||||
final ArrayList<Album> albums = new ArrayList<>();
|
||||
final ArrayList<Playlist> playlists = new ArrayList<>();
|
||||
final HashMap<Long, Track> byId = new HashMap<>();
|
||||
final HashMap<String, Artist> artistsByName = new HashMap<>();
|
||||
final HashMap<String, Album> albumsByKey = new HashMap<>();
|
||||
long stamp; // library.json mtime + size, to notice a re-sync
|
||||
|
||||
// ---- process-wide instance ----
|
||||
|
||||
private static final Handler MAIN = new Handler(Looper.getMainLooper());
|
||||
private static Library sLibrary;
|
||||
private static String sError;
|
||||
private static boolean sLoading;
|
||||
private static final ArrayList<Listener> sWaiting = new ArrayList<>();
|
||||
|
||||
static File root() {
|
||||
return new File(Environment.getExternalStorageDirectory(), "Music/andTunes");
|
||||
}
|
||||
|
||||
static File manifest() { return new File(root(), "library.json"); }
|
||||
|
||||
static Library get() { return sLibrary; }
|
||||
|
||||
/** Start loading if nothing is loaded yet, or the file changed under us. */
|
||||
static void preload() {
|
||||
if (sLoading) return;
|
||||
if (sLibrary != null && sLibrary.stamp == stampOf(manifest())) return;
|
||||
sLoading = true;
|
||||
new Thread(() -> {
|
||||
Library lib = null;
|
||||
String err = null;
|
||||
try {
|
||||
lib = load(manifest());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
err = describe(e);
|
||||
}
|
||||
final Library done = lib;
|
||||
final String error = err;
|
||||
MAIN.post(() -> {
|
||||
sLoading = false;
|
||||
if (done != null) { sLibrary = done; sError = null; }
|
||||
else if (sLibrary == null) sError = error;
|
||||
ArrayList<Listener> waiting = new ArrayList<>(sWaiting);
|
||||
sWaiting.clear();
|
||||
for (Listener l : waiting) l.onLibrary(sLibrary, sError);
|
||||
});
|
||||
}, "library-load").start();
|
||||
}
|
||||
|
||||
/** Call back on the main thread once a library (or an error) is ready. */
|
||||
static void whenReady(Listener listener) {
|
||||
if (!sLoading && (sLibrary != null || sError != null)) {
|
||||
listener.onLibrary(sLibrary, sError);
|
||||
return;
|
||||
}
|
||||
sWaiting.add(listener);
|
||||
preload();
|
||||
}
|
||||
|
||||
static void forget(Listener listener) { sWaiting.remove(listener); }
|
||||
|
||||
private static long stampOf(File f) { return f.lastModified() * 31 + f.length(); }
|
||||
|
||||
private static String describe(Exception e) {
|
||||
if (e instanceof java.io.FileNotFoundException) {
|
||||
return "No music yet.\n\nIn LinTunes: Connections → Sync to Rabbit.";
|
||||
}
|
||||
String msg = e.getMessage();
|
||||
return msg != null && msg.startsWith("andTunes:") ? msg.substring(9)
|
||||
: "Couldn't read library.json:\n" + e;
|
||||
}
|
||||
|
||||
// ---- parsing ----
|
||||
|
||||
static Library load(File file) throws IOException {
|
||||
Library lib = new Library();
|
||||
lib.stamp = stampOf(file);
|
||||
HashMap<String, List<Long>> playlistIds = new HashMap<>();
|
||||
int format = -1;
|
||||
try (JsonReader r = new JsonReader(new InputStreamReader(
|
||||
new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||
r.beginObject();
|
||||
while (r.hasNext()) {
|
||||
String key = r.nextName();
|
||||
if (key.equals("format")) {
|
||||
format = r.nextInt();
|
||||
checkFormat(format);
|
||||
} else if (key.equals("tracks")) {
|
||||
r.beginArray();
|
||||
while (r.hasNext()) lib.addTrack(readTrack(r));
|
||||
r.endArray();
|
||||
} else if (key.equals("playlists")) {
|
||||
r.beginArray();
|
||||
while (r.hasNext()) lib.playlists.add(readPlaylist(r, playlistIds));
|
||||
r.endArray();
|
||||
} else {
|
||||
r.skipValue();
|
||||
}
|
||||
}
|
||||
r.endObject();
|
||||
}
|
||||
checkFormat(format);
|
||||
for (Playlist p : lib.playlists) {
|
||||
for (Long id : playlistIds.get(p.id)) {
|
||||
Track t = lib.byId.get(id);
|
||||
if (t != null) p.tracks.add(t);
|
||||
}
|
||||
}
|
||||
lib.group();
|
||||
return lib;
|
||||
}
|
||||
|
||||
private static void checkFormat(int format) {
|
||||
if (format != FORMAT) {
|
||||
throw new IllegalStateException("andTunes:This library was written in format "
|
||||
+ format + ", and this andTunes only reads format " + FORMAT
|
||||
+ ".\n\nIn LinTunes: Connections → Install andTunes on Rabbit.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Track readTrack(JsonReader r) throws IOException {
|
||||
Track t = new Track();
|
||||
r.beginObject();
|
||||
while (r.hasNext()) {
|
||||
String key = r.nextName();
|
||||
if (r.peek() == JsonToken.NULL) { r.skipValue(); continue; }
|
||||
switch (key) {
|
||||
case "id": t.id = r.nextLong(); break;
|
||||
case "title": t.title = r.nextString(); break;
|
||||
case "path": t.path = r.nextString(); break;
|
||||
case "artist": t.artist = r.nextString(); break;
|
||||
case "album_artist": t.albumArtist = r.nextString(); break;
|
||||
case "album": t.album = r.nextString(); break;
|
||||
case "art": t.art = r.nextString(); break;
|
||||
case "year": t.year = r.nextInt(); break;
|
||||
case "track": t.number = r.nextInt(); break;
|
||||
case "disc": t.disc = r.nextInt(); break;
|
||||
case "secs": t.secs = r.nextInt(); break;
|
||||
default: r.skipValue();
|
||||
}
|
||||
}
|
||||
r.endObject();
|
||||
if (t.title.isEmpty()) {
|
||||
String name = new File(t.path).getName();
|
||||
int dot = name.lastIndexOf('.');
|
||||
t.title = dot > 0 ? name.substring(0, dot) : name;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
private static Playlist readPlaylist(JsonReader r, HashMap<String, List<Long>> ids)
|
||||
throws IOException {
|
||||
Playlist p = new Playlist();
|
||||
ArrayList<Long> members = new ArrayList<>();
|
||||
r.beginObject();
|
||||
while (r.hasNext()) {
|
||||
String key = r.nextName();
|
||||
switch (key) {
|
||||
case "id": p.id = r.nextString(); break;
|
||||
case "name": p.name = r.nextString(); break;
|
||||
case "tracks":
|
||||
r.beginArray();
|
||||
while (r.hasNext()) members.add(r.nextLong());
|
||||
r.endArray();
|
||||
break;
|
||||
default: r.skipValue();
|
||||
}
|
||||
}
|
||||
r.endObject();
|
||||
if (p.id == null) p.id = "p" + ids.size();
|
||||
if (p.name == null) p.name = "Untitled";
|
||||
ids.put(p.id, members);
|
||||
return p;
|
||||
}
|
||||
|
||||
private void addTrack(Track t) {
|
||||
t.sortKey = sortKey(t.title);
|
||||
t.haystack = (t.title + "\n" + t.artist + "\n" + t.albumArtist + "\n" + t.album)
|
||||
.toLowerCase(Locale.ROOT);
|
||||
tracks.add(t);
|
||||
byId.put(t.id, t);
|
||||
}
|
||||
|
||||
/** Artists and albums, grouped from the flat list in one pass. */
|
||||
private void group() {
|
||||
Collections.sort(tracks, (a, b) -> a.sortKey.compareTo(b.sortKey));
|
||||
for (Track t : tracks) {
|
||||
// Grouped by album artist when there is one, the way the desktop
|
||||
// files them, so a compilation stays one album under one name.
|
||||
String who = !t.albumArtist.isEmpty() ? t.albumArtist
|
||||
: !t.artist.isEmpty() ? t.artist : UNKNOWN_ARTIST;
|
||||
String albumName = t.album.isEmpty() ? UNKNOWN_ALBUM : t.album;
|
||||
String key = who.toLowerCase(Locale.ROOT) + " | ||||