Give the seek bar's interpolation interval one owner #165

Merged
logan merged 2 commits from fix/53-seek-bar-never-moves into main 2026-08-20 21:31:17 +00:00
Collaborator

Picked up #53 (Priority/High, the best use of an attached phone). It
does not reproduce on current main; what it did turn up is a real,
platform-independent defect in the same component, which is what this PR
fixes.

Commits

commit issue what
fe1fbef fix(player): give the seek bar's interval one owner #164 updated() owns the interval; the drag is @state; reports skipped mid-drag; 3 tests
67eeb75 docs(android): the device can be driven, not just looked at how to call a binding on the phone, and the #53 measurements

#53 is deliberately not closed. Full measurement posted on it,
Status/Need More Info, unassigned — see below.

The fix (#164)

Found by yonlu reading the component during the #122–#127 player audit,
split out because it is neither Android-specific nor #53's symptom.

handleInput() called stopProgress() and mutated no reactive
state
, so Lit scheduled no update, updated() never ran, and the tail
of updated() that restarts the interval never executed. Only a
change event or the next backend report could bring it back — so any
input that never commits froze the interpolation
: a drag cancelled
outside the element, a pointer taken by a scroll, a touch on the track
treated as a scrub. All ordinary gestures on a phone. While playing, the
1 Hz report papered over it within a second; with reports not arriving
it was permanent.

updated() owns the interval now, so one place decides whether it runs,
and handleChange no longer starts it directly.

The risk the fix introduces is the interesting part. A flag set on
input can strand, and that would turn a stall of up to a second into a
permanent one — the exact failure being removed. change is the
ordinary end of a drag; pointerup/pointercancel/touchend/
touchcancel on the document are the ends that are not, attached with
the drag and dropped with it (and on disconnect), because the pointer is
routinely released outside the element it started in.

Second half, same fix: a report arriving mid-drag used to overwrite
seekValue and pull the thumb out from under the finger once a second.
It is skipped while dragging, and its seq is deliberately left
unrecorded
so the first report after the drag still counts as fresh.

Verification

The guards are exercised — all three

Not just "the suite passes". Reverting the component and re-running:

× keeps ticking after a drag that never commits
× leaves the thumb where the finger is while a drag is live
  Tests  2 failed | 38 passed (40)

The third guards the stranding failure mode the fix introduces, so it
cannot fail against the old code. Removing the endDrag() from
handleChange to simulate a stranded flag:

× takes the backend back as the authority once the drag commits
  Tests  1 failed | 39 passed (40)

On the device (Chrome 113, the engine at risk)

Mid-drag — holds its value, interval stopped, reports ignored:

{"seekValue":100,"timerID":-1,"prevSeq":31,"elapsed":"01:40"}

On release — adopts the backend's real position and resumes ticking:

1s: {"seekValue":38,"timerID":75,"elapsed":"00:38"}
5s: {"seekValue":41,"timerID":83,"elapsed":"00:41"}

A real adb input swipe across the bar also leaves it ticking
(14 → 16 → 20).

The suite

make lint (3 configs, 0 issues), make test (3 configs), make ui-test (952 tests, was 949), make e2e (187 passed), tsc --noEmit in both packages, make css-check, make bindings-check,
make skill-check.

Formalities: everything except make ui-test and make skill-check.
This branch changes one TypeScript component, one test file and two
documents — no Go, no SQL, no bindings, no CSS — so lint, test, e2e,
css-check and bindings-check could not have been affected. Run and
reported anyway so the claim is checkable.

Why #53 is not closed here

seek-bar.ts and player-store.ts are byte-identical to v0.3.1,
the release on the reporter's phone — every change in that area since is
in backend/player/, which is where the #122–#127 audit landed, and
those shipped in v0.4.0.

Four scenarios on that phone against main, reading the component's own
state rather than a screenshot: mount-while-playing (28/240),
open-then-play (7/240), tap on the track (50 → 55), and an activity
recreation mid-playback (same pid, 30 → 35). All track correctly.

Reverting only backend/player/ to v0.3.1 reproduces a real defect
on the same phone — six seconds into a 20-second file with no database
row, played after a 240-second one, the bar reads 01:27 of 240.
That is #125's stale trackLengthMs, fixed at HEAD. But its shape is
not quite #53's: the fraction stays roughly right while the absolute
numbers lie, so it presents as a wrong clock rather than a handle pinned
at zero. That difference is exactly why this is a question for the
reporter and not a close.

Filed on the way past

  • #164 — the defect this PR fixes.
  • #162make android-emulator's boot wait can be satisfied by an
    unrelated attached device (from the #159 session).

Also worth knowing

#159's fix got its real-device outing here: wails3 task android:run:device was used repeatedly against a phone carrying the
released app, and app.yellowjacket v0.3.1 / firstInstallTime 2026-08-17 23:35:56 is unchanged. That is the task that used to
uninstall it.

The documentation commit carries the thing that cost the most time: the
runtime call does not go over HTTP on Android.
The WebView cannot
deliver a fetch() POST body to shouldInterceptRequest, so v3 routes
runtime calls through the addJavascriptInterface bridge — which means
.playwright/init-events.js does not transfer to the device, and its
/wails/runtime POST fails with missing object value, an error that
reads like a wrong payload shape and is actually no body at all. The
working recipe is now in android-tier.md, and it is what made the four
measurements above possible.

Closes #164

Picked up #53 (`Priority/High`, the best use of an attached phone). It does **not** reproduce on current `main`; what it did turn up is a real, platform-independent defect in the same component, which is what this PR fixes. ## Commits | commit | issue | what | |---|---|---| | `fe1fbef` fix(player): give the seek bar's interval one owner | **#164** | `updated()` owns the interval; the drag is `@state`; reports skipped mid-drag; 3 tests | | `67eeb75` docs(android): the device can be driven, not just looked at | — | how to call a binding on the phone, and the #53 measurements | **#53 is deliberately not closed.** Full measurement posted on it, `Status/Need More Info`, unassigned — see below. ## The fix (#164) Found by yonlu reading the component during the #122–#127 player audit, split out because it is neither Android-specific nor #53's symptom. `handleInput()` called `stopProgress()` and mutated **no reactive state**, so Lit scheduled no update, `updated()` never ran, and the tail of `updated()` that restarts the interval never executed. Only a `change` event or the next backend report could bring it back — so **any `input` that never commits froze the interpolation**: a drag cancelled outside the element, a pointer taken by a scroll, a touch on the track treated as a scrub. All ordinary gestures on a phone. While playing, the 1 Hz report papered over it within a second; with reports not arriving it was permanent. `updated()` owns the interval now, so one place decides whether it runs, and `handleChange` no longer starts it directly. **The risk the fix introduces is the interesting part.** A flag set on `input` can strand, and that would turn a stall of up to a second into a permanent one — the exact failure being removed. `change` is the ordinary end of a drag; `pointerup`/`pointercancel`/`touchend`/ `touchcancel` on the *document* are the ends that are not, attached with the drag and dropped with it (and on disconnect), because the pointer is routinely released outside the element it started in. Second half, same fix: a report arriving mid-drag used to overwrite `seekValue` and pull the thumb out from under the finger once a second. It is skipped while dragging, and its `seq` is **deliberately left unrecorded** so the first report after the drag still counts as fresh. ## Verification ### The guards are exercised — all three Not just "the suite passes". Reverting the component and re-running: ``` × keeps ticking after a drag that never commits × leaves the thumb where the finger is while a drag is live Tests 2 failed | 38 passed (40) ``` The third guards the stranding failure mode the fix introduces, so it cannot fail against the *old* code. Removing the `endDrag()` from `handleChange` to simulate a stranded flag: ``` × takes the backend back as the authority once the drag commits Tests 1 failed | 39 passed (40) ``` ### On the device (Chrome 113, the engine at risk) Mid-drag — holds its value, interval stopped, reports ignored: ``` {"seekValue":100,"timerID":-1,"prevSeq":31,"elapsed":"01:40"} ``` On release — adopts the backend's real position and resumes ticking: ``` 1s: {"seekValue":38,"timerID":75,"elapsed":"00:38"} 5s: {"seekValue":41,"timerID":83,"elapsed":"00:41"} ``` A real `adb input swipe` across the bar also leaves it ticking (14 → 16 → 20). ### The suite `make lint` (3 configs, 0 issues), `make test` (3 configs), `make ui-test` (**952** tests, was 949), `make e2e` (187 passed), `tsc --noEmit` in both packages, `make css-check`, `make bindings-check`, `make skill-check`. **Formalities:** everything except `make ui-test` and `make skill-check`. This branch changes one TypeScript component, one test file and two documents — no Go, no SQL, no bindings, no CSS — so lint, test, e2e, css-check and bindings-check could not have been affected. Run and reported anyway so the claim is checkable. ## Why #53 is not closed here `seek-bar.ts` and `player-store.ts` are **byte-identical** to `v0.3.1`, the release on the reporter's phone — every change in that area since is in `backend/player/`, which is where the #122–#127 audit landed, and those shipped in **v0.4.0**. Four scenarios on that phone against `main`, reading the component's own state rather than a screenshot: mount-while-playing (28/240), open-then-play (7/240), tap on the track (50 → 55), and an activity recreation mid-playback (same pid, 30 → 35). All track correctly. Reverting **only** `backend/player/` to v0.3.1 reproduces a real defect on the same phone — six seconds into a 20-second file with no database row, played after a 240-second one, the bar reads **`01:27` of 240**. That is #125's stale `trackLengthMs`, fixed at HEAD. But its *shape* is not quite #53's: the fraction stays roughly right while the absolute numbers lie, so it presents as a wrong clock rather than a handle pinned at zero. That difference is exactly why this is a question for the reporter and not a close. ## Filed on the way past - **#164** — the defect this PR fixes. - **#162** — `make android-emulator`'s boot wait can be satisfied by an unrelated attached device (from the #159 session). ## Also worth knowing `#159`'s fix got its real-device outing here: `wails3 task android:run:device` was used repeatedly against a phone carrying the released app, and `app.yellowjacket` v0.3.1 / `firstInstallTime 2026-08-17 23:35:56` is unchanged. That is the task that used to uninstall it. The documentation commit carries the thing that cost the most time: **the runtime call does not go over HTTP on Android.** The WebView cannot deliver a `fetch()` POST body to `shouldInterceptRequest`, so v3 routes runtime calls through the `addJavascriptInterface` bridge — which means `.playwright/init-events.js` does not transfer to the device, and its `/wails/runtime` POST fails with `missing object value`, an error that reads like a wrong payload shape and is actually no body at all. The working recipe is now in `android-tier.md`, and it is what made the four measurements above possible. Closes #164
logan added 2 commits 2026-08-20 21:18:53 +00:00
`handleInput()` called `stopProgress()` and mutated no reactive state,
so Lit scheduled no update, `updated()` never ran, and the tail of
`updated()` that restarts the interval never executed. Only a `change`
event or the next backend report could bring it back — so an `input`
that never commits froze the interpolation: a drag cancelled outside
the element, a pointer taken by a scroll, or a touch on the track
treated as a scrub, all ordinary gestures on a phone. While playing the
1 Hz report papered over it within a second; with reports not arriving
it was permanent.

The drag is `@state` now and `updated()` decides whether the interval
runs, so there is one place that knows. `handleChange` no longer starts
it directly for the same reason.

A flag set on `input` can strand, which would turn a stall of up to a
second into a permanent one — the failure this removes. `change` is the
ordinary end; `pointerup`/`pointercancel`/`touchend`/`touchcancel` on
the document are the ends that are not, attached with the drag and
dropped with it, because the pointer is routinely released outside the
element it started in.

The other half is that a report arriving mid-drag used to overwrite
`seekValue` and pull the thumb out from under the finger once a second.
It is skipped while dragging, and its seq is deliberately left
unrecorded so the first report after the drag still counts as fresh.

Three tests, all exercised against the fault: two fail on the old
component, and the third fails if the drag flag is left set — which is
the failure mode the fix introduces and the listeners exist to prevent.
Verified on the device too (Chrome 113): mid-drag the bar holds its
value and ignores reports, and on release it adopts the backend's real
position and resumes ticking.

Closes #164
docs(android): the device can be driven, not just looked at
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m59s
67eeb75e7b
The runtime call does not go over HTTP on Android — the WebView cannot
deliver a fetch() POST body to shouldInterceptRequest, so v3 routes
runtime calls through the addJavascriptInterface bridge. Two things
follow that cost an hour each before the v3 source was read:
`.playwright/init-events.js` does not transfer to the device (its
outbound half hooks fetch, and a POST to /wails/runtime answers
"missing object value" — which reads like a wrong payload and is the
interceptor getting no body at all), and hooking fetch from an eval is
too late on any platform because the bundle captured its reference at
module scope.

The recipe that does work goes in, along with how to get audio onto the
phone (scoped storage silently swallows a push into
/sdcard/Android/data/<pkg>/files, and the fixtures are 2 seconds long,
which is useless for watching a seek bar) and the permission dialog a
reinstall raises, which looks exactly like the app failing to start.

NOTES.md takes the #53 measurements: that its frontend is byte-identical
to the v0.3.1 the phone carries, that the symptom does not reproduce on
main in four scenarios, and that reverting only backend/player/ to
v0.3.1 reproduces #125 instead — with the shim that makes that a
ten-minute experiment rather than a full checkout.
logan merged commit c19a806298 into main 2026-08-20 21:31:17 +00:00
Sign in to join this conversation.