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.

Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9
This commit is contained in:
2026-09-11 14:25:55 -05:00
parent d335e9144b
commit 077479967a
51 changed files with 2963 additions and 98 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.18.0"
__version__ = "0.19.0"
+6
View File
@@ -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.
"""
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"version_name": "0.1.0", "version_code": 1}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+180
View File
@@ -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.")
+38 -2
View File
@@ -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/<name>.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.
+81
View File
@@ -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):