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
136 lines
4.4 KiB
Python
136 lines
4.4 KiB
Python
#!/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/<name>.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()
|