v0.20.4: the song that wasn't there
trav couldn't play the first track of his playlist. Shuffle off gave him song two; shuffle on gave him a random one. The player was right both times: library.json named a song whose file was not on the device, and skipping an unplayable file advances one slot. Tapping any song that *is* there plays exactly that song, shuffle or not. The file was missing because a gvfs-MTP mount can hold a phantom directory -- one it lists happily while the device has no such folder. Every write into it fails EIO, and mkdir(exist_ok=True) sees the phantom and does nothing, so it never heals; only remounting clears it. That folder was new because Round 52's retag moved the file under a new artist. So a copy that raises OSError now costs that song, not the sync, and the song is left out of the m3u and library.json. Aborting cost 2,400 songs for one folder; counting it present anyway put a song in the manifest that isn't on the device, which is the bug you could hear. The names come back in the summary and a dialog rather than vanishing. Planning is a worker now. plan_andtunes_sync is pure and writes nothing, but it walks every file on the device, and over MTP that is thousands of round trips -- on the GUI thread it froze the window and GNOME offered to kill LinTunes, which is how a sync got force-quit halfway through. And the sync stopped going quiet at the end. "Album art 501/501" is emitted before the last album, and then _write_index, _ship_buttons and _prune_empty_dirs ran silently: 501 exists() 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. andTunes 0.2.2: the wheel scrolls the other way in lists, and smoothly -- 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. Volume is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
This commit is contained in:
+4
-1
@@ -11,7 +11,10 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||
`Library.group()` keyed albums case-folded but artists raw-case, so one artist
|
||||
spelled two ways on one album ("RJD2" / "Rjd2") took the whole library down
|
||||
with `Couldn't read library.json`. Both maps fold case now, and `Artist` has a
|
||||
`key` the way `Album` always did.** What's left is the *Parked* list, pulled in
|
||||
`key` the way `Album` always did. 0.2.2 (round 55, 2026-09-14) reversed the
|
||||
wheel in lists and made it scroll smoothly (`WheelScroll` — a pixel debt paid
|
||||
off per frame, not a row per detent); volume on the other screens is
|
||||
unchanged.** What's left is the *Parked* list, pulled in
|
||||
as trav finds he needs it. Built with
|
||||
`python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see
|
||||
`README.md`, *Toolchain*). Phases are andTunes' own; which LinTunes round
|
||||
|
||||
@@ -52,6 +52,7 @@ public class ListActivity extends BaseActivity {
|
||||
private String mode, id;
|
||||
private TextView titleBar;
|
||||
private ListView list;
|
||||
private WheelScroll wheel;
|
||||
private EditText searchBox;
|
||||
private final RowAdapter adapter = new RowAdapter();
|
||||
private final ArrayList<Row> rows = new ArrayList<>();
|
||||
@@ -84,6 +85,7 @@ public class ListActivity extends BaseActivity {
|
||||
list.setBackgroundColor(Ui.WHITE);
|
||||
list.setAdapter(adapter);
|
||||
list.setOnItemClickListener((parent, view, position, rowId) -> onRow(rows.get(position)));
|
||||
wheel = new WheelScroll(list);
|
||||
column.addView(list, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
|
||||
@@ -95,6 +97,7 @@ public class ListActivity extends BaseActivity {
|
||||
|
||||
@Override protected void onDestroy() {
|
||||
Library.forget(onLoaded);
|
||||
wheel.stop();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@@ -103,8 +106,9 @@ public class ListActivity extends BaseActivity {
|
||||
adapter.notifyDataSetChanged(); // re-mark the playing row
|
||||
}
|
||||
|
||||
/** Wheel up moves the list down, the way pushing the page up does. */
|
||||
@Override protected void onWheel(int dir) {
|
||||
list.scrollListBy(-dir * Ui.dp(this, Ui.ROW_DP));
|
||||
wheel.push(dir * Ui.dp(this, WheelScroll.STEP_DP));
|
||||
}
|
||||
|
||||
private String defaultTitle() {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package me.teafry.andtunes;
|
||||
|
||||
import android.view.Choreographer;
|
||||
import android.widget.ListView;
|
||||
|
||||
/**
|
||||
* Smooth 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>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.
|
||||
*/
|
||||
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;
|
||||
|
||||
private final ListView list;
|
||||
private float debt;
|
||||
private boolean running;
|
||||
|
||||
WheelScroll(ListView list) {
|
||||
this.list = list;
|
||||
}
|
||||
|
||||
/** One detent, {@code px} signed pixels in the direction to travel. */
|
||||
void push(float px) {
|
||||
debt += px;
|
||||
if (!running) {
|
||||
running = true;
|
||||
Choreographer.getInstance().postFrameCallback(this);
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
Choreographer.getInstance().postFrameCallback(this);
|
||||
} else {
|
||||
debt = 0;
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop any motion still owed (the screen is going away). */
|
||||
void stop() {
|
||||
debt = 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.1"
|
||||
VERSION_CODE = 3
|
||||
VERSION_NAME = "0.2.2"
|
||||
VERSION_CODE = 4
|
||||
|
||||
PACKAGE = "me.teafry.andtunes"
|
||||
MIN_SDK = 26
|
||||
|
||||
Reference in New Issue
Block a user