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 —
|
row would be 4.4 mm tall. Do not "fix" this by changing the device density —
|
||||||
trav likes it where it is.
|
trav likes it where it is.
|
||||||
- Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free.
|
- 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**,
|
- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**,
|
||||||
mapped by the stock `Generic.kl` (checked 2026-09-11 via `dumpsys input`)
|
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
|
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. */
|
/** Wheel up moves the list down, the way pushing the page up does. */
|
||||||
@Override protected void onWheel(int dir) {
|
@Override protected void onWheel(int dir) {
|
||||||
wheel.push(dir * Ui.dp(this, WheelScroll.STEP_DP));
|
wheel.push(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String defaultTitle() {
|
private String defaultTitle() {
|
||||||
|
|||||||
@@ -1,67 +1,112 @@
|
|||||||
package me.teafry.andtunes;
|
package me.teafry.andtunes;
|
||||||
|
|
||||||
|
import android.os.SystemClock;
|
||||||
import android.view.Choreographer;
|
import android.view.Choreographer;
|
||||||
import android.widget.ListView;
|
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
|
* <p>The wheel is a Hall latch (<code>och1970_holl_key</code>) and the driver
|
||||||
* reads as a teleport — the list jumps a track at a time instead of gliding
|
* exposes nothing but <code>KEY_UP</code> / <code>KEY_DOWN</code> — one key
|
||||||
* the way a finger drag does. So a detent doesn't scroll: it adds to a pixel
|
* press per detent, no <code>REL_WHEEL</code>, no resolution. So there is no
|
||||||
* <em>debt</em>, and a Choreographer callback pays a fraction of that debt off
|
* finer signal to read: all the nuance has to come from <em>when</em> the
|
||||||
* every frame. One detent eases to a stop; a fast spin piles debt up faster
|
* detents arrive, not how far the wheel turned.
|
||||||
* than it drains and becomes one continuous movement.
|
|
||||||
*
|
*
|
||||||
* <p>Frame-driven rather than animated per detent, because a new detent has to
|
* <p>Which is what this does. A detent is an impulse into a velocity, not a
|
||||||
* blend into the motion already underway — restarting an animation each time
|
* jump: the list then moves at that velocity and coasts to a stop, so one
|
||||||
* is what makes wheel scrolling stutter.
|
* 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 {
|
final class WheelScroll implements Choreographer.FrameCallback {
|
||||||
/** Fraction of the outstanding debt paid per frame: the easing curve. */
|
/** How far one unhurried detent travels, all told, in dp. */
|
||||||
private static final float FRICTION = 0.28f;
|
private static final float STEP_DP = 44f;
|
||||||
/** Below this the remainder is paid in one go rather than halved forever. */
|
/** What the fastest detent is worth, as a multiple of {@link #STEP_DP}. */
|
||||||
private static final float SETTLE_PX = 1.5f;
|
private static final float MAX_GAIN = 8f;
|
||||||
/** How far one detent travels. Deliberately not a row height. */
|
/** Detents this close together (ms) earn the whole gain. */
|
||||||
static final int STEP_DP = 52;
|
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 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;
|
private boolean running;
|
||||||
|
|
||||||
WheelScroll(ListView list) {
|
WheelScroll(ListView list) {
|
||||||
this.list = list;
|
this.list = list;
|
||||||
|
this.density = list.getResources().getDisplayMetrics().density;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One detent, {@code px} signed pixels in the direction to travel. */
|
/** One detent. {@code dir} is +1 or -1 in the direction to travel. */
|
||||||
void push(float px) {
|
void push(int dir) {
|
||||||
debt += px;
|
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) {
|
if (!running) {
|
||||||
running = true;
|
running = true;
|
||||||
|
lastFrameNs = 0;
|
||||||
Choreographer.getInstance().postFrameCallback(this);
|
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) {
|
@Override public void doFrame(long frameTimeNanos) {
|
||||||
float step = Math.abs(debt) <= SETTLE_PX ? debt : debt * FRICTION;
|
float dt = lastFrameNs == 0 ? 1f / 60f
|
||||||
int px = Math.round(step);
|
: (frameTimeNanos - lastFrameNs) / 1_000_000_000f;
|
||||||
if (px != 0) {
|
lastFrameNs = frameTimeNanos;
|
||||||
list.scrollListBy(px);
|
dt = Math.min(dt, 0.05f); // a dropped frame shouldn't lurch
|
||||||
debt -= px; // what actually moved, so hitting either end ends it
|
|
||||||
} else {
|
float move = velocity * dt + residue;
|
||||||
debt = 0;
|
int px = (int) move;
|
||||||
}
|
residue = move - px;
|
||||||
if (Math.abs(debt) >= 0.5f) {
|
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);
|
Choreographer.getInstance().postFrameCallback(this);
|
||||||
} else {
|
} else {
|
||||||
debt = 0;
|
stop();
|
||||||
running = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop any motion still owed (the screen is going away). */
|
/** Drop any motion still owed (the screen is going away). */
|
||||||
void stop() {
|
void stop() {
|
||||||
debt = 0;
|
velocity = 0;
|
||||||
|
residue = 0;
|
||||||
|
lastFrameNs = 0;
|
||||||
running = false;
|
running = false;
|
||||||
Choreographer.getInstance().removeFrameCallback(this);
|
Choreographer.getInstance().removeFrameCallback(this);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -26,8 +26,8 @@ import sys
|
|||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION_NAME = "0.2.2"
|
VERSION_NAME = "0.2.3"
|
||||||
VERSION_CODE = 4
|
VERSION_CODE = 5
|
||||||
|
|
||||||
PACKAGE = "me.teafry.andtunes"
|
PACKAGE = "me.teafry.andtunes"
|
||||||
MIN_SDK = 26
|
MIN_SDK = 26
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""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
|
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
|
line frozen. They report now, and `_write_index` reads `Art/` in one
|
||||||
listing instead of a stat per album.
|
listing instead of a stat per album.
|
||||||
- [x] **The wheel scrolls smoothly, and the other way** (`WheelScroll`). A
|
- [x] **The wheel scrolls kinetically, and the other way** (`WheelScroll`;
|
||||||
detent adds to a pixel debt that a Choreographer callback pays off a
|
v0.20.5 / andTunes 0.2.3 refined the first pass). `getevent -pl` settled
|
||||||
fraction per frame, so one detent eases to a stop and a fast spin blends
|
what's possible: `och1970_holl_key` advertises `KEY_UP KEY_DOWN` and
|
||||||
into one movement instead of teleporting a row at a time. Direction
|
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.
|
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)
|
### Round 54 (2026-09-14) — one artist spelled two ways (v0.20.3, andTunes 0.2.1)
|
||||||
|
|||||||
Reference in New Issue
Block a user