v0.20.5: the wheel learns to coast

getevent settled what the wheel can and can't tell us: och1970_holl_key
advertises KEY_UP and 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 without a kernel driver
change, so all the nuance has to come from *when* the detents arrive.

So a detent is now an impulse into a velocity rather than a jump. The list
coasts and eases out, and an impulse is worth up to 8x more when detents come
30 ms apart than when they come 220 ms apart -- squared, so the gain stays out
of the way while you're hunting for one row. 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
instead of fighting the glide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
This commit is contained in:
2026-09-14 13:51:42 -07:00
co-authored by Claude Opus 5
parent efa07b05c6
commit f091a545f5
8 changed files with 100 additions and 41 deletions
+7
View File
@@ -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
@@ -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() {
@@ -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.
*
* <p>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
* <em>debt</em>, 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.
* <p>The wheel is a Hall latch (<code>och1970_holl_key</code>) and the driver
* exposes nothing but <code>KEY_UP</code> / <code>KEY_DOWN</code> — one key
* press per detent, no <code>REL_WHEEL</code>, no resolution. So there is no
* finer signal to read: all the nuance has to come from <em>when</em> the
* detents arrive, not how far the wheel turned.
*
* <p>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.
* <p>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}.
*
* <p>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);
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.20.4"
__version__ = "0.20.5"
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
{"version_name": "0.2.2", "version_code": 4}
{"version_name": "0.2.3", "version_code": 5}
+11 -4
View File
@@ -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)