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:
2026-09-14 13:25:15 -07:00
co-authored by Claude Opus 5
parent def7635a87
commit efa07b05c6
12 changed files with 457 additions and 36 deletions
@@ -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);
}
}