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:
@@ -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
@@ -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,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 @@
|
||||
{"version_name": "0.2.2", "version_code": 4}
|
||||
{"version_name": "0.2.3", "version_code": 5}
|
||||
|
||||
+11
-4
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user