diff --git a/andtunes/TASKS.md b/andtunes/TASKS.md index 5271602..8a275f4 100644 --- a/andtunes/TASKS.md +++ b/andtunes/TASKS.md @@ -29,6 +29,13 @@ 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. +- **The wheel can only ever give us detents.** `getevent -pl` (2026-09-14): + `och1970_holl_key` advertises `KEY (0001): KEY_UP KEY_DOWN` and *nothing + else* — no `EV_REL`/`REL_WHEEL`, no `EV_ABS`, no resolution. The `och1970` + is a Hall latch, so the driver quantises the magnet into clicks and hands + userspace one key press each. There is no sub-detent position to read + without a kernel driver change, so every bit of nuance has to be derived + from the *timing* between detents (see `WheelScroll`). - 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 diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java index d09ebd4..ed9a552 100644 --- a/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java +++ b/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java @@ -108,7 +108,7 @@ public class ListActivity extends BaseActivity { /** Wheel up moves the list down, the way pushing the page up does. */ @Override protected void onWheel(int dir) { - wheel.push(dir * Ui.dp(this, WheelScroll.STEP_DP)); + wheel.push(dir); } private String defaultTitle() { diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/WheelScroll.java b/andtunes/app/src/main/java/me/teafry/andtunes/WheelScroll.java index fcff0ab..273ee81 100644 --- a/andtunes/app/src/main/java/me/teafry/andtunes/WheelScroll.java +++ b/andtunes/app/src/main/java/me/teafry/andtunes/WheelScroll.java @@ -1,67 +1,112 @@ package me.teafry.andtunes; +import android.os.SystemClock; import android.view.Choreographer; import android.widget.ListView; /** - * Smooth scrolling for the R1's wheel. + * Kinetic scrolling for the R1's wheel. * - *

The wheel arrives as discrete detents, and moving a whole row per detent - * reads as a teleport — the list jumps a track at a time instead of gliding - * the way a finger drag does. So a detent doesn't scroll: it adds to a pixel - * debt, and a Choreographer callback pays a fraction of that debt off - * every frame. One detent eases to a stop; a fast spin piles debt up faster - * than it drains and becomes one continuous movement. + *

The wheel is a Hall latch (och1970_holl_key) and the driver + * exposes nothing but KEY_UP / KEY_DOWN — one key + * press per detent, no REL_WHEEL, no resolution. So there is no + * finer signal to read: all the nuance has to come from when the + * detents arrive, not how far the wheel turned. * - *

Frame-driven rather than animated per detent, because a new detent has to - * blend into the motion already underway — restarting an animation each time - * is what makes wheel scrolling stutter. + *

Which is what this does. A detent is an impulse into a velocity, not a + * jump: the list then moves at that velocity and coasts to a stop, so one + * click nudges and a spin glides. And an impulse is worth more the faster the + * detents come — {@link #MAX_GAIN}× at a hard spin — so a long list crosses in + * a flick while a single click still lands a row or so. Turning back the other + * way kills the glide first, so a correction bites immediately instead of + * fighting the coast. */ final class WheelScroll implements Choreographer.FrameCallback { - /** Fraction of the outstanding debt paid per frame: the easing curve. */ - private static final float FRICTION = 0.28f; - /** Below this the remainder is paid in one go rather than halved forever. */ - private static final float SETTLE_PX = 1.5f; - /** How far one detent travels. Deliberately not a row height. */ - static final int STEP_DP = 52; + /** How far one unhurried detent travels, all told, in dp. */ + private static final float STEP_DP = 44f; + /** What the fastest detent is worth, as a multiple of {@link #STEP_DP}. */ + private static final float MAX_GAIN = 8f; + /** Detents this close together (ms) earn the whole gain. */ + private static final float FAST_MS = 30f; + /** Detents this far apart (ms) earn none of it. */ + private static final float SLOW_MS = 220f; + /** Fraction of the speed still there one second into a coast. */ + private static final float KEEP_PER_S = 0.05f; + /** Below this (dp/s) the glide has finished. */ + private static final float STOP_DP_S = 18f; + /** Ceiling, so a frantic spin stays readable rather than teleporting. */ + private static final float MAX_DP_S = 9000f; private final ListView list; - private float debt; + private final float density; + + private float velocity; // px/s, positive = toward the end of the list + private float residue; // sub-pixel remainder, so slow moves aren't lost + private long lastDetentMs; + private long lastFrameNs; private boolean running; WheelScroll(ListView list) { this.list = list; + this.density = list.getResources().getDisplayMetrics().density; } - /** One detent, {@code px} signed pixels in the direction to travel. */ - void push(float px) { - debt += px; + /** One detent. {@code dir} is +1 or -1 in the direction to travel. */ + void push(int dir) { + long now = SystemClock.uptimeMillis(); + float gap = lastDetentMs == 0 ? SLOW_MS : now - lastDetentMs; + lastDetentMs = now; + + float impulse = dir * STEP_DP * gainFor(gap) * density * -(float) Math.log(KEEP_PER_S); + if (velocity != 0 && Math.signum(impulse) != Math.signum(velocity)) { + velocity = 0; // a change of mind stops the coast, then steers + residue = 0; + } + float cap = MAX_DP_S * density; + velocity = Math.max(-cap, Math.min(cap, velocity + impulse)); if (!running) { running = true; + lastFrameNs = 0; Choreographer.getInstance().postFrameCallback(this); } } + /** + * 1 at {@link #SLOW_MS} apart, {@link #MAX_GAIN} at {@link #FAST_MS}. + * + *

Squared, so the gain stays out of the way until the wheel is really + * moving — a slow hunt for one row shouldn't feel twitchy. + */ + private static float gainFor(float gapMs) { + float t = (SLOW_MS - Math.min(Math.max(gapMs, FAST_MS), SLOW_MS)) + / (SLOW_MS - FAST_MS); + return 1f + (MAX_GAIN - 1f) * t * t; + } + @Override public void doFrame(long frameTimeNanos) { - float step = Math.abs(debt) <= SETTLE_PX ? debt : debt * FRICTION; - int px = Math.round(step); - if (px != 0) { - list.scrollListBy(px); - debt -= px; // what actually moved, so hitting either end ends it - } else { - debt = 0; - } - if (Math.abs(debt) >= 0.5f) { + float dt = lastFrameNs == 0 ? 1f / 60f + : (frameTimeNanos - lastFrameNs) / 1_000_000_000f; + lastFrameNs = frameTimeNanos; + dt = Math.min(dt, 0.05f); // a dropped frame shouldn't lurch + + float move = velocity * dt + residue; + int px = (int) move; + residue = move - px; + if (px != 0) list.scrollListBy(px); + + velocity *= (float) Math.pow(KEEP_PER_S, dt); + if (Math.abs(velocity) >= STOP_DP_S * density) { Choreographer.getInstance().postFrameCallback(this); } else { - debt = 0; - running = false; + stop(); } } /** Drop any motion still owed (the screen is going away). */ void stop() { - debt = 0; + velocity = 0; + residue = 0; + lastFrameNs = 0; running = false; Choreographer.getInstance().removeFrameCallback(this); } diff --git a/andtunes/build.py b/andtunes/build.py index 808e8fb..95c380d 100644 --- a/andtunes/build.py +++ b/andtunes/build.py @@ -26,8 +26,8 @@ import sys import zipfile from pathlib import Path -VERSION_NAME = "0.2.2" -VERSION_CODE = 4 +VERSION_NAME = "0.2.3" +VERSION_CODE = 5 PACKAGE = "me.teafry.andtunes" MIN_SDK = 26 diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 911530f..34a78be 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.20.4" +__version__ = "0.20.5" diff --git a/lintunes/android/andTunes.apk b/lintunes/android/andTunes.apk index 5f2e8fa..56aae0c 100644 Binary files a/lintunes/android/andTunes.apk and b/lintunes/android/andTunes.apk differ diff --git a/lintunes/android/andTunes.json b/lintunes/android/andTunes.json index 6cbac99..488f188 100644 --- a/lintunes/android/andTunes.json +++ b/lintunes/android/andTunes.json @@ -1 +1 @@ -{"version_name": "0.2.2", "version_code": 4} +{"version_name": "0.2.3", "version_code": 5} diff --git a/tasks-done.md b/tasks-done.md index 930cb52..4e4dc37 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -31,10 +31,17 @@ that *is* there plays exactly that song, shuffle or not. stats over MTP, ~840 KB of writes and a full tree walk with the progress line frozen. They report now, and `_write_index` reads `Art/` in one listing instead of a stat per album. -- [x] **The wheel scrolls smoothly, and the other way** (`WheelScroll`). A - detent adds to a pixel debt that a Choreographer callback pays off a - fraction per frame, so one detent eases to a stop and a fast spin blends - into one movement instead of teleporting a row at a time. Direction +- [x] **The wheel scrolls kinetically, and the other way** (`WheelScroll`; + v0.20.5 / andTunes 0.2.3 refined the first pass). `getevent -pl` settled + what's possible: `och1970_holl_key` advertises `KEY_UP KEY_DOWN` and + nothing else — no `REL_WHEEL`, no resolution, because the `och1970` is a + Hall latch the driver quantises into clicks. There is no sub-detent + position to read, so every bit of nuance comes from the *timing*. A + detent is an impulse into a velocity rather than a jump: the list coasts + and eases out, and an impulse is worth up to 8× more when detents arrive + 30 ms apart instead of 220 ms. Measured on the device: one unhurried + detent moves about half a row, twelve fast ones move forty-odd. Turning + back the other way kills the coast first so a correction bites. Direction flipped in lists only; volume on the other screens is unchanged. ### Round 54 (2026-09-14) — one artist spelled two ways (v0.20.3, andTunes 0.2.1)