App 2.46.0: Día de Muertos shows what plays

A strip of papel picado with the song waves across the sky, towed by a
monarch butterfly and, on the next pass, an alebrije. One flag colour per
song. The banner moved into the shared SongBanner; Halloween is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-11-02 17:48:35 +01:00
co-authored by Claude Sonnet 5.5
parent a445fef407
commit cdf78c6d32
8 changed files with 278 additions and 64 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 162
versionName "2.45.2"
versionCode 163
versionName "2.46.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -6,7 +6,6 @@ import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PathMeasure;
import android.graphics.RadialGradient;
import android.graphics.RectF;
import android.graphics.Shader;
@@ -51,8 +50,7 @@ final class HalloweenView extends ClusterCanvasView {
private final Paint image = new Paint(Paint.FILTER_BITMAP_FLAG);
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path = new Path();
private final Path baseline = new Path();
private final PathMeasure measure = new PathMeasure();
private final SongBanner banner = new SongBanner();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
@@ -291,71 +289,23 @@ final class HalloweenView extends ClusterCanvasView {
if (v.songTitle == null) return false;
text.setTypeface(face);
text.setTextSize(19f * u);
float room = Math.min(560f * u, w * 0.45f);
String artist = v.songArtist == null ? "" : " — " + cut(v.songArtist, room / 2.5f);
// A long title gives way, so the artist stays.
String s = "♪ " + cut(v.songTitle, room - text.measureText("♪ " + artist)) + artist;
String s = SongBanner.line(text, v.songTitle, v.songArtist, Math.min(560f * u, w * 0.45f));
float t = SystemClock.uptimeMillis() / 1000f;
float bw = text.measureText(s) + 40f * u, bh = 30f * u, tow = 56f * u;
// One pass takes about half a minute; then it comes round again from the left.
float travel = w + bw + tow + 80f * u;
float bx = (t * 70f * u) % travel - bw - tow - 40f * u;
float bx = SongBanner.left(t, w, bw, tow, u);
// In the strip of sky above the sign, clear of it.
float yc = 34f * u + (float) Math.sin(t * 1.3f) * 4f * u;
float top = yc - bh / 2f, bottom = yc + bh / 2f, right = bx + bw, notch = 12f * u;
// The ribbon, waving in the wind: still where the bat holds it, rippling more towards its
// tail, which is cut as a swallowtail. The text follows the waves.
int steps = Math.max(8, Math.round(bw / (12f * u)));
path.reset();
for (int i = 0; i <= steps; i++) {
float x = right - bw * i / steps;
if (i == 0) path.moveTo(x, top + wave(x, right, bw, t, u));
else path.lineTo(x, top + wave(x, right, bw, t, u));
}
path.lineTo(bx + notch, yc + wave(bx + notch, right, bw, t, u));
for (int i = steps; i >= 0; i--) path.lineTo(right - bw * i / steps, bottom + wave(right - bw * i / steps, right, bw, t, u));
path.close();
fill.setColor(BONE);
c.drawPath(path, fill);
line.setColor(0xFF8A6A3A);
line.setStrokeWidth(2f * u);
c.drawPath(path, line);
baseline.reset();
for (int i = 0; i <= steps; i++) {
float x = bx + notch + (bw - notch) * i / steps;
if (i == 0) baseline.moveTo(x, yc + wave(x, right, bw, t, u));
else baseline.lineTo(x, yc + wave(x, right, bw, t, u));
}
measure.setPath(baseline, false);
text.setColor(INK);
text.setTextAlign(Paint.Align.LEFT);
c.drawTextOnPath(s, baseline, Math.max(0f, (measure.getLength() - text.measureText(s)) / 2f), 7f * u, text);
banner.draw(c, fill, line, text, s, bx, yc, bw, bh, t, u, 0f, BONE, 0xFF8A6A3A, INK);
// The tow lines, and the bat at their end.
float batX = right + tow, batY = yc - 10f * u;
float right = bx + bw, batX = right + tow, batY = yc - 10f * u;
line.setColor(BAT_EDGE);
line.setStrokeWidth(1.5f * u);
c.drawLine(right, top + 3f * u, batX, batY + 6f * u, line);
c.drawLine(right, bottom - 3f * u, batX, batY + 6f * u, line);
c.drawLine(right, yc - bh / 2f + 3f * u, batX, batY + 6f * u, line);
c.drawLine(right, yc + bh / 2f - 3f * u, batX, batY + 6f * u, line);
bat(c, batX, batY, 26f * u, (float) Math.sin(t * 12f), u);
return true;
}
/**
* How far the banner bends at {@code x}: nothing at the bat's end ({@code right}), up to 7 units
* at the tail, in waves that run from the bat to the tail.
*/
private static float wave(float x, float right, float bw, float t, float u) {
float along = right - x;
return 7f * u * (along / bw) * (float) Math.sin(along / (170f * u) * 2f * Math.PI - t * 5f);
}
/** {@code s} cut with "…" to fit {@code room} in the text paint as set. */
private String cut(String s, float room) {
if (text.measureText(s) <= room) return s;
int fits = text.breakText(s, true, Math.max(0f, room - text.measureText("…")), null);
return s.substring(0, Math.max(0, fits)).trim() + "…";
}
// ---------- The arch of embers: the power ----------
private void drawEmbers(Canvas c, int w, float u) {
@@ -1785,7 +1785,8 @@ public class MainActivity extends AppCompatActivity {
/** The styles that show what plays. */
private static boolean showsNowPlaying(OverviewStyle style) {
return style == OverviewStyle.TILES || style == OverviewStyle.HALLOWEEN;
return style == OverviewStyle.TILES || style == OverviewStyle.HALLOWEEN
|| style == OverviewStyle.MUERTOS;
}
/** The media sessions are watched only while what plays can be seen, and the switch is on. */
@@ -44,6 +44,7 @@ final class MuertosView extends ClusterCanvasView {
private final Paint image = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
private final Path path = new Path();
private final Path flag = new Path();
private final SongBanner banner = new SongBanner();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
@@ -87,6 +88,7 @@ final class MuertosView extends ClusterCanvasView {
c.drawBitmap(scene, 0, 0, image);
float cx = w / 2f, cy = 345f * u;
drawFlags(c, w, u);
boolean song = drawSong(c, w, u);
drawRing(c, cx, cy, u);
drawSpeed(c, cx, cy, u);
drawBattery(c, u);
@@ -95,7 +97,7 @@ final class MuertosView extends ClusterCanvasView {
drawClock(c, w, u);
drawGears(c, w, u);
drawRows(c, w, h, u);
keepAnimating(moving, 33);
keepAnimating(moving || song, 33);
}
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
@@ -330,6 +332,165 @@ final class MuertosView extends ClusterCanvasView {
}
}
// ---------- What plays: a monarch or an alebrije tows it on a strip of papel picado ----------
/**
* A monarch butterfly (the souls' messengers) and an alebrije (Oaxaca's painted spirit creature)
* take turns towing a waving strip of cut paper with the song across the sky between the flags
* and the ring, behind the ring and the clock, again and again while something plays. The paper's
* colour is one of the flags', fixed by the song. Returns whether it is drawn, so the view keeps
* animating for it.
*/
private boolean drawSong(Canvas c, int w, float u) {
if (v.songTitle == null) return false;
text.setTypeface(face);
text.setTextSize(19f * u);
String s = SongBanner.line(text, v.songTitle, v.songArtist, Math.min(560f * u, w * 0.45f));
float t = SystemClock.uptimeMillis() / 1000f;
float bw = text.measureText(s) + 40f * u, bh = 28f * u, tow = 64f * u;
float bx = SongBanner.left(t, w, bw, tow, u);
float yc = 106f * u + (float) Math.sin(t * 1.3f) * 2f * u;
int paper = PAPER[Math.floorMod((v.songTitle + "\n" + v.songArtist).hashCode(), PAPER.length)];
fill.setStyle(Paint.Style.FILL);
banner.draw(c, fill, line, text, s, bx, yc, bw, bh, t, u, 14f * u, paper, 0xCC2A0E36,
paper == PAPER[4] ? BONE : INK);
boolean butterfly = SongBanner.pass(t, w, bw, tow, u) % 2 == 0;
float right = bx + bw, cx = right + tow, cy = yc - 6f * u, size = (butterfly ? 40f : 36f) * u;
// The tow lines run to the tail.
float ax = cx - (butterfly ? 0.4f : 0.6f) * size, ay = cy - (butterfly ? 0f : 0.35f * size);
line.setColor(0x99FFFFFF);
line.setStrokeWidth(1.5f * u);
c.drawLine(right, yc - bh / 2f + 3f * u, ax, ay, line);
c.drawLine(right, yc + bh / 2f - 3f * u, ax, ay, line);
float flap = (float) Math.sin(t * (butterfly ? 9f : 7f));
if (butterfly) monarch(c, cx, cy, size, flap, u);
else alebrije(c, cx, cy, size, flap, u);
return true;
}
/** A monarch seen from above, flying right: orange wings veined and edged in black, white dots. */
private void monarch(Canvas c, float x, float y, float s, float flap, float u) {
float k = 0.3f + 0.7f * Math.abs(flap);
c.save();
c.translate(x, y);
for (int side = 0; side < 2; side++) {
c.save();
if (side == 1) c.scale(1f, -1f);
path.reset();
path.moveTo(0.3f * s, 0);
path.quadTo(0.3f * s, -0.75f * s * k, -0.02f * s, -0.95f * s * k);
path.quadTo(-0.45f * s, -0.7f * s * k, -0.1f * s, 0);
path.close();
path.moveTo(-0.05f * s, 0);
path.quadTo(-0.4f * s, -0.55f * s * k, -0.7f * s, -0.4f * s * k);
path.quadTo(-0.7f * s, -0.1f * s * k, -0.3f * s, 0);
path.close();
fill.setColor(0xFFFF8A1F);
c.drawPath(path, fill);
line.setColor(0xFF1A0A10);
line.setStrokeWidth(2.2f * u);
line.setStrokeJoin(Paint.Join.ROUND);
c.drawPath(path, line);
line.setStrokeWidth(1.2f * u);
c.drawLine(0.1f * s, 0, -0.02f * s, -0.88f * s * k, line);
c.drawLine(0.05f * s, 0, -0.3f * s, -0.62f * s * k, line);
c.drawLine(-0.1f * s, 0, -0.62f * s, -0.36f * s * k, line);
fill.setColor(0xFFFFFFFF);
for (float[] d : new float[][] { { 0.14f, -0.74f }, { -0.1f, -0.88f }, { -0.3f, -0.72f }, { -0.62f, -0.36f } }) {
c.drawCircle(d[0] * s, d[1] * s * k, 1.6f * u, fill);
}
c.restore();
}
fill.setColor(0xFF1A0A10);
r.set(-0.4f * s, -0.07f * s, 0.35f * s, 0.07f * s);
c.drawOval(r, fill);
c.drawCircle(0.38f * s, 0, 0.08f * s, fill);
line.setColor(0xFF1A0A10);
line.setStrokeWidth(1.5f * u);
c.drawLine(0.42f * s, -0.04f * s, 0.62f * s, -0.2f * s, line);
c.drawLine(0.42f * s, 0.04f * s, 0.62f * s, 0.2f * s, line);
c.restore();
}
/** An alebrije flying right: a painted horned creature with feathered wings and a curled tail. */
private void alebrije(Canvas c, float x, float y, float s, float flap, float u) {
int[] feathers = { TURQUOISE, GOLD, PINK, MARIGOLD, PAPER[4] };
c.save();
c.translate(x, y);
// The wing, behind the body: feathers fanned from the shoulder, lifted and dropped by the flap.
for (int i = 0; i < feathers.length; i++) {
c.save();
c.translate(-0.05f * s, -0.2f * s);
c.rotate(-125f + flap * 30f + i * 15f);
r.set(0, -0.07f * s, 0.8f * s, 0.07f * s);
fill.setColor(feathers[i]);
c.drawOval(r, fill);
line.setColor(INK);
line.setStrokeWidth(1.5f * u);
c.drawOval(r, line);
c.restore();
}
// The curled tail.
path.reset();
path.moveTo(-0.4f * s, 0.05f * s);
path.cubicTo(-0.8f * s, 0.1f * s, -0.85f * s, -0.3f * s, -0.6f * s, -0.35f * s);
line.setColor(PINK);
line.setStrokeWidth(0.1f * s);
c.drawPath(path, line);
fill.setColor(GOLD);
c.drawCircle(-0.6f * s, -0.35f * s, 0.07f * s, fill);
// Legs.
line.setColor(INK);
line.setStrokeWidth(3f * u);
c.drawLine(-0.25f * s, 0.15f * s, -0.3f * s, 0.42f * s, line);
c.drawLine(0.15f * s, 0.15f * s, 0.2f * s, 0.42f * s, line);
fill.setColor(GOLD);
c.drawCircle(-0.3f * s, 0.43f * s, 0.05f * s, fill);
c.drawCircle(0.2f * s, 0.43f * s, 0.05f * s, fill);
// The body, painted with stripes and dots.
r.set(-0.45f * s, -0.2f * s, 0.35f * s, 0.2f * s);
fill.setColor(PINK);
c.drawOval(r, fill);
line.setColor(INK);
line.setStrokeWidth(2f * u);
c.drawOval(r, line);
line.setColor(TURQUOISE);
line.setStrokeWidth(0.05f * s);
path.reset();
path.moveTo(-0.35f * s, 0f);
for (int i = 1; i <= 6; i++) path.lineTo((-0.35f + 0.1f * i) * s, (i % 2 == 0 ? 0f : -0.07f) * s);
c.drawPath(path, line);
fill.setColor(GOLD);
for (float[] d : new float[][] { { -0.3f, 0.1f }, { -0.1f, 0.12f }, { 0.1f, 0.1f }, { 0.25f, 0.05f } }) {
c.drawCircle(d[0] * s, d[1] * s, 0.035f * s, fill);
}
// Horns, the head, snout, eye.
line.setColor(GOLD);
line.setStrokeWidth(3.5f * u);
path.reset();
path.moveTo(0.35f * s, -0.38f * s);
path.quadTo(0.2f * s, -0.55f * s, 0.3f * s, -0.78f * s);
path.moveTo(0.5f * s, -0.38f * s);
path.quadTo(0.65f * s, -0.52f * s, 0.55f * s, -0.78f * s);
c.drawPath(path, line);
fill.setColor(MARIGOLD);
c.drawCircle(0.42f * s, -0.22f * s, 0.2f * s, fill);
line.setColor(INK);
line.setStrokeWidth(2f * u);
c.drawCircle(0.42f * s, -0.22f * s, 0.2f * s, line);
r.set(0.5f * s, -0.22f * s, 0.85f * s, -0.06f * s);
fill.setColor(GOLD);
c.drawOval(r, fill);
c.drawOval(r, line);
fill.setColor(PINK);
c.drawCircle(0.8f * s, -0.15f * s, 0.035f * s, fill);
fill.setColor(BONE);
c.drawCircle(0.45f * s, -0.28f * s, 0.07f * s, fill);
fill.setColor(INK);
c.drawCircle(0.47f * s, -0.28f * s, 0.035f * s, fill);
c.restore();
}
private void drawBattery(Canvas c, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
float x = 36f * u;
@@ -0,0 +1,97 @@
package com.evsuite.abrp;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PathMeasure;
/**
* The strip of paper a creature tows across a style's sky with the song on it, shared by the styles
* that show what plays (Halloween's parchment, Día de Muertos's papel picado). The ribbon waves in
* the wind: still where the creature holds it, rippling more towards its tail, which is cut as a
* swallowtail, and the text follows the waves. The style draws the creature and the tow lines. Keeps
* only the paths it reuses between frames.
*/
final class SongBanner {
private static final float SPEED = 70f;
private final Path path = new Path();
private final Path baseline = new Path();
private final PathMeasure measure = new PathMeasure();
/** One pass takes about half a minute; then the creature comes round again from the left. */
static float travel(int w, float bw, float tow, float u) {
return w + bw + tow + 80f * u;
}
/** Where the banner's left end is at {@code t} seconds. */
static float left(float t, int w, float bw, float tow, float u) {
return (t * SPEED * u) % travel(w, bw, tow, u) - bw - tow - 40f * u;
}
/** How many times it has crossed by {@code t}, to let two creatures take turns. */
static int pass(float t, int w, float bw, float tow, float u) {
return (int) (t * SPEED * u / travel(w, bw, tow, u));
}
/**
* Draws the banner from {@code bx} to {@code bx + bw}, centred on {@code yc}. {@code tooth} above
* zero cuts both long edges in zigzags of that width (cut paper). The text paint is set up by the
* caller (typeface and size); its colour and alignment are set here.
*/
void draw(Canvas c, Paint fill, Paint line, Paint text, String s, float bx, float yc, float bw, float bh,
float t, float u, float tooth, int paper, int edge, int ink) {
float top = yc - bh / 2f, bottom = yc + bh / 2f, right = bx + bw, notch = 12f * u;
float cut = tooth > 0f ? 3.5f * u : 0f;
int steps = Math.max(8, Math.round(bw / (tooth > 0f ? tooth / 2f : 12f * u)));
path.reset();
for (int i = 0; i <= steps; i++) {
float x = right - bw * i / steps;
float y = top + wave(x, right, bw, t, u) - (i % 2 == 1 ? cut : 0f);
if (i == 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
path.lineTo(bx + notch, yc + wave(bx + notch, right, bw, t, u));
for (int i = steps; i >= 0; i--) {
float x = right - bw * i / steps;
path.lineTo(x, bottom + wave(x, right, bw, t, u) + (i % 2 == 1 ? cut : 0f));
}
path.close();
fill.setColor(paper);
c.drawPath(path, fill);
line.setColor(edge);
line.setStrokeWidth(2f * u);
c.drawPath(path, line);
baseline.reset();
for (int i = 0; i <= steps; i++) {
float x = bx + notch + (bw - notch) * i / steps;
if (i == 0) baseline.moveTo(x, yc + wave(x, right, bw, t, u));
else baseline.lineTo(x, yc + wave(x, right, bw, t, u));
}
measure.setPath(baseline, false);
text.setColor(ink);
text.setTextAlign(Paint.Align.LEFT);
c.drawTextOnPath(s, baseline, Math.max(0f, (measure.getLength() - text.measureText(s)) / 2f), 7f * u, text);
}
/**
* How far the banner bends at {@code x}: nothing at the creature's end ({@code right}), up to 7
* units at the tail, in waves that run from the creature to the tail.
*/
private static float wave(float x, float right, float bw, float t, float u) {
float along = right - x;
return 7f * u * (along / bw) * (float) Math.sin(along / (170f * u) * 2f * Math.PI - t * 5f);
}
/** "♪ title — artist" in the text paint as set, cut with "…" to fit {@code room}; the title gives way first. */
static String line(Paint text, String title, String artist, float room) {
String a = artist == null ? "" : " — " + cut(text, artist, room / 2.5f);
return "♪ " + cut(text, title, room - text.measureText("♪ " + a)) + a;
}
private static String cut(Paint text, String s, float room) {
if (text.measureText(s) <= room) return s;
int fits = text.breakText(s, true, Math.max(0f, room - text.measureText("…")), null);
return s.substring(0, Math.max(0, fits)).trim() + "…";
}
}