Android: the seek bar handle on Now Playing never moves #53

Open
opened 2026-08-18 05:58:15 +00:00 by logan · 3 comments
Collaborator

Report

The seek bar is broken on the Now Playing view — the handle stays at the start no matter how far the song has progressed.

Findings

  • now-playing-view composes the real <seek-bar>, so the bar works elsewhere and fails here. That points at the report not reaching this instance rather than at the bar itself.
  • seek-bar renders PlaybackPositionChanged (payload player.PositionInfo), emitted at 1Hz while playing and immediately on load/play/pause/seek/finish. Each report carries a trackChangeIda bar mounting later must not adopt a report about the previous track, and the store is a singleton. A view created on navigation mounts its bar mid-track, so a trackChangeId mismatch (or a missing initial report for a late subscriber) would produce exactly this symptom: zero progress, forever.
  • The local setInterval is interpolation between reports only, stopped and restarted by each one — with no reports accepted, it never starts.

Direction

Check what a seek-bar mounting mid-playback does with trackChangeId and whether it gets a snapshot of the current position on mount (rather than waiting for a report it will reject). Add a spec that mounts a bar while a track is already playing. This is likely a desktop bug too, hidden because the desktop bar mounts at startup and never remounts.

**Report** The seek bar is broken on the Now Playing view — the handle stays at the start no matter how far the song has progressed. **Findings** - `now-playing-view` composes the real `<seek-bar>`, so the bar works elsewhere and fails here. That points at the report not reaching this instance rather than at the bar itself. - `seek-bar` renders `PlaybackPositionChanged` (payload `player.PositionInfo`), emitted at 1Hz while playing and immediately on load/play/pause/seek/finish. Each report carries a `trackChangeId` — **a bar mounting later must not adopt a report about the previous track**, and the store is a singleton. A view created on navigation mounts its bar mid-track, so a `trackChangeId` mismatch (or a missing initial report for a late subscriber) would produce exactly this symptom: zero progress, forever. - The local `setInterval` is interpolation *between* reports only, stopped and restarted by each one — with no reports accepted, it never starts. **Direction** Check what a `seek-bar` mounting mid-playback does with `trackChangeId` and whether it gets a snapshot of the current position on mount (rather than waiting for a report it will reject). Add a spec that mounts a bar while a track is already playing. This is likely a desktop bug too, hidden because the desktop bar mounts at startup and never remounts.
logan added the Area/PlayerKind/BugPlatform/Android
Priority
High
2
labels 2026-08-18 14:36:21 +00:00
Owner

Two things found while auditing the player for a desktop report of "pause icon showing, bar not moving" (#122–#125). Neither is claimed here; filing them where whoever picks this up will see them.

The stated hypothesis does not survive reading updated(). A seek-bar mounting mid-track is not left rejecting reports:

  • previousTrackChangeId starts at -1, so the first updated() always takes the track-change branch, sets it to the live id and seeds seekValue from currentTrack.seekPosition;
  • forThisTrack then compares the store's position.trackChangeId against that same live id — they match, because both come from p.trackChangeID and emitTrackChanged is the only thing that increments it;
  • previousPositionSeq starts at -1, so any report's seq differs and is applied.

So a late-mounting bar adopts the current position on its first update and starts interpolating. If the Android bar still shows zero, the report is not reaching the store (or wa-slider's .value is not reflecting in Chrome 113 — that WebView is two years behind everything else we test on), rather than being rejected by the bar. Worth confirming with window.__yjEvents on the device before changing the component.

A real way the interval stops and does not restart, on any platform. handleInput() calls stopProgress() and changes no reactive state, so no re-render follows and the updated() tail that would restart the interval never runs. It is only restarted by a change event or by the next accepted backend report. Any input without a committed change — a touch that lands on the track and is treated as a scrub, a drag cancelled outside the element — therefore freezes the bar until the next 1 Hz report rescues it. On a touch device that is the common gesture, and if reports are also not arriving it is permanent. Cheap fix: make the drag an @state flag and let updated() own the interval, so there is one place that decides whether it runs.

Two things found while auditing the player for a desktop report of "pause icon showing, bar not moving" (#122–#125). Neither is claimed here; filing them where whoever picks this up will see them. **The stated hypothesis does not survive reading `updated()`.** A `seek-bar` mounting mid-track is *not* left rejecting reports: - `previousTrackChangeId` starts at `-1`, so the first `updated()` always takes the track-change branch, sets it to the live id and seeds `seekValue` from `currentTrack.seekPosition`; - `forThisTrack` then compares the store's `position.trackChangeId` against that same live id — they match, because both come from `p.trackChangeID` and `emitTrackChanged` is the only thing that increments it; - `previousPositionSeq` starts at `-1`, so any report's `seq` differs and is applied. So a late-mounting bar adopts the current position on its first update and starts interpolating. If the Android bar still shows zero, the report is not reaching the *store* (or `wa-slider`'s `.value` is not reflecting in Chrome 113 — that WebView is two years behind everything else we test on), rather than being rejected by the bar. Worth confirming with `window.__yjEvents` on the device before changing the component. **A real way the interval stops and does not restart, on any platform.** `handleInput()` calls `stopProgress()` and changes no reactive state, so no re-render follows and the `updated()` tail that would restart the interval never runs. It is only restarted by a `change` event or by the next accepted backend report. Any `input` without a committed `change` — a touch that lands on the track and is treated as a scrub, a drag cancelled outside the element — therefore freezes the bar until the next 1 Hz report rescues it. On a touch device that is the common gesture, and if reports are *also* not arriving it is permanent. Cheap fix: make the drag an `@state` flag and let `updated()` own the interval, so there is one place that decides whether it runs.
logan self-assigned this 2026-08-20 20:57:37 +00:00
logan added the
Status
In Progress
label 2026-08-20 20:57:37 +00:00
Author
Collaborator

Taking this on fix/53-seek-bar-never-moves.

Starting from yonlu's comment rather than the issue's own hypothesis,
which that comment shows does not survive reading updated()
previousTrackChangeId starts at -1, so a late-mounting bar always
takes the track-change branch and adopts the live position.

Plan, in that order:

  1. Measure on the device before changing anything. A phone is
    attached. The question yonlu left open is whether
    PlaybackPositionChanged reaches the store at all on Android, or
    whether it does and wa-slider's .value is not reflecting in
    Chrome 113 — those need opposite fixes and the page can be asked
    directly (make android-inspect / android-eval).
  2. Fix the platform-independent freeze yonlu found: handleInput()
    calls stopProgress() and changes no reactive state, so nothing
    re-renders and the updated() tail that would restart the interval
    never runs. On a touch device a tap on the track is the common
    gesture.
  3. Guard it in the browser tier. seek-bar has no test at any
    tier, which is why a component this load-bearing could be broken on
    one platform without anything failing. A mid-playback mount is
    assertable with wails-fake.ts, so this need not stay device-gated.

app.yellowjacket (the released app, v0.3.1) will not be touched:
anything installed here is the .dev variant, via the tasks fixed in
#159.

Taking this on `fix/53-seek-bar-never-moves`. Starting from yonlu's comment rather than the issue's own hypothesis, which that comment shows does not survive reading `updated()` — `previousTrackChangeId` starts at `-1`, so a late-mounting bar always takes the track-change branch and adopts the live position. Plan, in that order: 1. **Measure on the device before changing anything.** A phone is attached. The question yonlu left open is whether `PlaybackPositionChanged` reaches the *store* at all on Android, or whether it does and `wa-slider`'s `.value` is not reflecting in Chrome 113 — those need opposite fixes and the page can be asked directly (`make android-inspect` / `android-eval`). 2. **Fix the platform-independent freeze** yonlu found: `handleInput()` calls `stopProgress()` and changes no reactive state, so nothing re-renders and the `updated()` tail that would restart the interval never runs. On a touch device a tap on the track is the common gesture. 3. **Guard it in the browser tier.** `seek-bar` has no test at any tier, which is why a component this load-bearing could be broken on one platform without anything failing. A mid-playback mount is assertable with `wails-fake.ts`, so this need not stay device-gated. `app.yellowjacket` (the released app, v0.3.1) will not be touched: anything installed here is the `.dev` variant, via the tasks fixed in #159.
Author
Collaborator

Measured on the reported device against current main. The symptom
does not reproduce, and the most likely reason is that it was a backend
fault already fixed in v0.4.0.
Detail below so this can be judged
rather than taken on trust — I have not closed it, because the person
who saw it should get to say.

The component is not what changed.

$ git diff v0.3.1 HEAD -- frontend/src/components/audio-player/seekbar/ \
                          frontend/src/store/player-store.ts
(empty)

seek-bar.ts and player-store.ts are byte-identical to the build the
phone is carrying (the released v0.3.1, firstInstallTime
2026-08-17). Every change in this area since is in backend/player/
which is exactly where the player audit (#122–#127) landed, and those
are in v0.4.0, not v0.3.1.

Four scenarios on the phone (TLP301, Android 14, Chrome 113), current
main, with a synthesised 4-minute track
, reading the component's own
state rather than a screenshot:

scenario result
navigate to Now Playing while already playing seekValue 28, slider 28, max 240, 00:28 — tracking
open Now Playing first, then start playback 7 / 240 after 7s — tracking
tap on the bar's track keeps advancing, 50 → 55
activity recreation mid-playback (font_scale) same pid, bar resumes 30 → 35

The issue's stated hypothesis is dead, as your comment said it would be,
and there is already a test for it — transport.test.ts's "resumes
mid-track from the position the backend reported". I had gone looking
for a missing seek-bar test and found the suite is in
transport.test.ts, not a file of its own; it is 20 tests deep.

What does reproduce, on the same phone. Reverting only
backend/player/ to v0.3.1 — frontend and everything else at HEAD —
brings back a real position defect. Six seconds into a 20-second file
with no database row, played after a 240-second one:

{"seekValue":87,"sliderValue":87,"sliderMax":240,"elapsed":"01:27"}

That is #125's stale trackLengthMs ("cleared only by UnloadTrack, so
a file with no row inherited the previous track's duration"), and it is
fixed at HEAD. Worth noting its shape, because it is not quite what
this issue describes: the fraction stays roughly right while the
absolute numbers are wrong, so it presents as a clock that lies rather
than as a handle pinned at zero. If what you saw was the clock
disagreeing with the music, that is this and it is fixed. If the handle
really was at the far left with the clock counting normally, something
else is going on and this should stay open.

(The v0.3.1 backend/player compiles against HEAD with one shim —
SetPlaybackFinishedHandler gained a srcErr error parameter — which
is what made that a ten-minute experiment.)

Your second finding was real and is now fixed as #164:
handleInput() stopped the interval and mutated no reactive state, so
nothing re-rendered and only a change or the next report could restart
it. updated() owns the interval now, the drag is @state, and
pointerup/pointercancel/touchend/touchcancel on the document
stop the flag stranding — which would have converted a one-second stall
into a permanent one. Reports are ignored mid-drag too, so the thumb is
no longer pulled out from under the finger once a second. Three tests,
all checked against the fault.

What would settle this: does it still happen on v0.4.0 or later?
That is the first build with the player fixes. Marking
Status/Need More Info and unassigning so it is free for you or whoever
can answer that.

**Measured on the reported device against current `main`. The symptom does not reproduce, and the most likely reason is that it was a backend fault already fixed in v0.4.0.** Detail below so this can be judged rather than taken on trust — I have not closed it, because the person who saw it should get to say. **The component is not what changed.** ``` $ git diff v0.3.1 HEAD -- frontend/src/components/audio-player/seekbar/ \ frontend/src/store/player-store.ts (empty) ``` `seek-bar.ts` and `player-store.ts` are byte-identical to the build the phone is carrying (the released `v0.3.1`, `firstInstallTime` 2026-08-17). Every change in this area since is in `backend/player/` — which is exactly where the player audit (#122–#127) landed, and those are in **v0.4.0**, not v0.3.1. **Four scenarios on the phone (TLP301, Android 14, Chrome 113), current `main`, with a synthesised 4-minute track**, reading the component's own state rather than a screenshot: | scenario | result | |---|---| | navigate to Now Playing while already playing | `seekValue` 28, slider 28, max 240, `00:28` — tracking | | open Now Playing first, then start playback | 7 / 240 after 7s — tracking | | tap on the bar's track | keeps advancing, 50 → 55 | | activity recreation mid-playback (`font_scale`) | same pid, bar resumes 30 → 35 | The issue's stated hypothesis is dead, as your comment said it would be, and there is already a test for it — `transport.test.ts`'s "resumes mid-track from the position the backend reported". I had gone looking for a missing `seek-bar` test and found the suite is in `transport.test.ts`, not a file of its own; it is 20 tests deep. **What does reproduce, on the same phone.** Reverting *only* `backend/player/` to v0.3.1 — frontend and everything else at HEAD — brings back a real position defect. Six seconds into a 20-second file with no database row, played after a 240-second one: ``` {"seekValue":87,"sliderValue":87,"sliderMax":240,"elapsed":"01:27"} ``` That is #125's stale `trackLengthMs` ("cleared only by `UnloadTrack`, so a file with no row inherited the previous track's duration"), and it is fixed at HEAD. Worth noting its *shape*, because it is not quite what this issue describes: the fraction stays roughly right while the absolute numbers are wrong, so it presents as a clock that lies rather than as a handle pinned at zero. If what you saw was the **clock** disagreeing with the music, that is this and it is fixed. If the handle really was at the far left with the clock counting normally, something else is going on and this should stay open. (The v0.3.1 `backend/player` compiles against HEAD with one shim — `SetPlaybackFinishedHandler` gained a `srcErr error` parameter — which is what made that a ten-minute experiment.) **Your second finding was real and is now fixed** as #164: `handleInput()` stopped the interval and mutated no reactive state, so nothing re-rendered and only a `change` or the next report could restart it. `updated()` owns the interval now, the drag is `@state`, and `pointerup`/`pointercancel`/`touchend`/`touchcancel` on the document stop the flag stranding — which would have converted a one-second stall into a permanent one. Reports are ignored mid-drag too, so the thumb is no longer pulled out from under the finger once a second. Three tests, all checked against the fault. **What would settle this**: does it still happen on **v0.4.0 or later**? That is the first build with the player fixes. Marking `Status/Need More Info` and unassigning so it is free for you or whoever can answer that.
logan removed their assignment 2026-08-20 21:17:56 +00:00
logan added
Status
Need More Info
2
and removed
Status
In Progress
labels 2026-08-20 21:18:00 +00:00
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yonlu/yellowjacket#53