Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67eeb75e7b | ||
|
|
fe1fbefee7 | ||
|
|
de04339494 |
@@ -515,6 +515,75 @@ Four things about it, each of which costs an hour if met cold:
|
|||||||
script. Plug in over USB for anything longer than a couple of probes.
|
script. Plug in over USB for anything longer than a couple of probes.
|
||||||
- **The socket name carries the pid**, which changes on every launch, so
|
- **The socket name carries the pid**, which changes on every launch, so
|
||||||
it is resolved rather than remembered.
|
it is resolved rather than remembered.
|
||||||
|
- **A reinstall resets the runtime permissions**, and the grant dialog
|
||||||
|
is a separate activity that takes focus — so the app is up, `am start`
|
||||||
|
reports "delivered to currently running top-most instance", and
|
||||||
|
`pidof` is empty because it never got to the foreground.
|
||||||
|
`dumpsys window | grep mCurrentFocus` naming
|
||||||
|
`GrantPermissionsActivity` is the tell. `adb shell pm grant
|
||||||
|
app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
|
||||||
|
`POST_NOTIFICATIONS`) ahead of the launch skips it.
|
||||||
|
|
||||||
|
### Calling a binding on the device
|
||||||
|
|
||||||
|
**The runtime call does not go over HTTP on Android**, and this is worth
|
||||||
|
knowing before an hour is spent on it. The WebView cannot deliver a
|
||||||
|
`fetch()` POST body to `shouldInterceptRequest`, so v3 routes runtime
|
||||||
|
calls through the `addJavascriptInterface` bridge instead: the
|
||||||
|
@wailsio/runtime installs a `customTransport` that calls
|
||||||
|
`window.wails.invokeAsync(id, payload)` and receives the answer on
|
||||||
|
`window._wailsAndroidCallback`. Two consequences:
|
||||||
|
|
||||||
|
- **`.playwright/init-events.js` does not transfer to the device.** Its
|
||||||
|
outbound half hooks `fetch`, which sees nothing here, and its
|
||||||
|
`call()` posts to `/wails/runtime`, which answers
|
||||||
|
`Invalid runtime call: missing object value` — the interceptor got the
|
||||||
|
URL with no body. Its *inbound* half is still right, because
|
||||||
|
`dispatchWailsEvent` is the entry point in every mode.
|
||||||
|
- **Hooking `fetch` from an eval is too late anyway**, on any platform:
|
||||||
|
the bundle captured its reference at module scope, so a wrapper
|
||||||
|
installed afterwards records nothing. That is why the harness is an
|
||||||
|
`initScript` and not a step in a spec.
|
||||||
|
|
||||||
|
What works is to borrow the bridge, chaining the runtime's own callback
|
||||||
|
so its pending calls still resolve:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const pending = new Map();
|
||||||
|
const prev = window._wailsAndroidCallback;
|
||||||
|
window._wailsAndroidCallback = (id, response, error) => {
|
||||||
|
if (!pending.has(id)) return prev && prev(id, response, error);
|
||||||
|
const p = pending.get(id); pending.delete(id);
|
||||||
|
const env = JSON.parse(response || "{}");
|
||||||
|
return env.ok ? p.resolve(env.data ?? env.text) : p.reject(new Error(env.error));
|
||||||
|
};
|
||||||
|
window.__yj = { call(name, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = "yj" + Math.random().toString(36).slice(2);
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
window.wails.invokeAsync(id, JSON.stringify({
|
||||||
|
object: 0, method: 0, windowName: "",
|
||||||
|
args: { "call-id": id, methodName: "yellowjacket/backend/" + name, args: args || [] },
|
||||||
|
clientId: window._wails.clientId,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} };
|
||||||
|
```
|
||||||
|
|
||||||
|
That turns the device into a tier that can be *driven* rather than only
|
||||||
|
looked at — `__yj.call("player.Player.LoadFile", [path])` and
|
||||||
|
`__yj.call("library.Library.AddLibrary", ["/sdcard/Music/..."])` are how
|
||||||
|
#53 was measured. Names are the Go ones (`GetTracks`, not
|
||||||
|
`GetAllTracks`); an unknown one comes back as a plain
|
||||||
|
`unknown bound method name`, so a wrong guess is loud.
|
||||||
|
|
||||||
|
**Getting audio onto the phone**: `adb push` into
|
||||||
|
`/sdcard/Android/data/<pkg>/files/` looks like it works and then the
|
||||||
|
files are not there — scoped storage. `/sdcard/Music/...` plus
|
||||||
|
`pm grant … READ_MEDIA_AUDIO` does work, and `AddLibrary` takes the
|
||||||
|
plain path. The generated fixtures are **~2 seconds** each, which is
|
||||||
|
fine for a scan and useless for watching a seek bar, so synthesise a
|
||||||
|
long one: `ffmpeg -f lavfi -i sine=frequency=440:duration=240`.
|
||||||
|
|
||||||
**And the reason to bother: the phone is an engine, not a screen.** The
|
**And the reason to bother: the phone is an engine, not a screen.** The
|
||||||
first device here renders in **Chrome 113** at 424x439 CSS px. Every
|
first device here renders in **Chrome 113** at 424x439 CSS px. Every
|
||||||
|
|||||||
@@ -4242,3 +4242,63 @@ took another ~10 s to come up.
|
|||||||
Harmless here (the emulator was up before anything used it) and a
|
Harmless here (the emulator was up before anything used it) and a
|
||||||
straightforward race otherwise: `start` should wait for a device that
|
straightforward race otherwise: `start` should wait for a device that
|
||||||
is an emulator, not for whatever `pick_device` returns. Filed as #162.
|
is an emulator, not for whatever `pick_device` returns. Filed as #162.
|
||||||
|
|
||||||
|
## The Android runtime transport is not HTTP (measured 2026-08-20)
|
||||||
|
|
||||||
|
Found while trying to drive the phone for #53. `wails3` routes runtime
|
||||||
|
calls through `addJavascriptInterface` on Android, not through
|
||||||
|
`/wails/runtime` — the WebView cannot deliver a `fetch()` POST body to
|
||||||
|
`shouldInterceptRequest`, which the v3 source says in as many words
|
||||||
|
(`application_android.go`, "The Android transport"). The runtime
|
||||||
|
installs a `customTransport` over `window.wails.invokeAsync(id,
|
||||||
|
payload)` and takes the answer on `window._wailsAndroidCallback`.
|
||||||
|
|
||||||
|
Two things follow, and both cost time before the source was read:
|
||||||
|
|
||||||
|
- **`.playwright/init-events.js` does not transfer to the device.** Its
|
||||||
|
outbound half hooks `fetch`; a POST to `/wails/runtime` answers
|
||||||
|
`Invalid runtime call: missing object value`, which reads like a
|
||||||
|
wrong payload shape and is actually the interceptor receiving a URL
|
||||||
|
with no body at all. The payload shape was right the whole time. Its
|
||||||
|
*inbound* half is still correct, because `dispatchWailsEvent` is the
|
||||||
|
entry point in every mode.
|
||||||
|
- **Hooking `fetch` from an `eval` is too late on any platform.** The
|
||||||
|
bundle captured its reference at module scope, so a wrapper installed
|
||||||
|
afterwards records nothing — which is exactly why the harness is an
|
||||||
|
`initScript`. Measured: zero calls captured while the app was
|
||||||
|
demonstrably making them.
|
||||||
|
|
||||||
|
The working recipe is in `android-tier.md`; it chains the runtime's own
|
||||||
|
callback rather than replacing it, so its pending calls still resolve.
|
||||||
|
This is what makes the device a tier that can be *driven*.
|
||||||
|
|
||||||
|
## #53's frontend is byte-identical to the build it was reported against (2026-08-20)
|
||||||
|
|
||||||
|
`git diff v0.3.1 HEAD -- frontend/src/components/audio-player/seekbar/
|
||||||
|
frontend/src/store/player-store.ts` is **empty**; the whole diff in that
|
||||||
|
area is `backend/player/`. The phone carries the released `v0.3.1`, so
|
||||||
|
whatever #53 saw, the component was not what changed — and v0.4.0 is
|
||||||
|
where the player audit (#122–#127) landed.
|
||||||
|
|
||||||
|
Measured on that phone, current `main`, with a synthesised 4-minute
|
||||||
|
track: the Now Playing seek bar tracks correctly when mounted
|
||||||
|
mid-playback (`seekValue` 28 of 240), when the view is opened before
|
||||||
|
playback starts, after a tap on the track, and across an activity
|
||||||
|
recreation (same pid, bar resumes at 30 → 35). The issue's stated
|
||||||
|
symptom did not reproduce in any of them.
|
||||||
|
|
||||||
|
Reverting **only** `backend/player/` to v0.3.1 — the frontend and
|
||||||
|
everything else at HEAD — does reproduce a real position defect on the
|
||||||
|
same device: six seconds into a 20-second file with no database row,
|
||||||
|
played after a 240-second one, the bar read **01:27 of 240**. 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. Note the *shape* of it: the fraction is roughly right and the
|
||||||
|
absolute numbers are wrong, so it presents as a clock that lies rather
|
||||||
|
than as a handle that will not move.
|
||||||
|
|
||||||
|
The one-line experiment is worth remembering: v0.3.1's `backend/player`
|
||||||
|
compiles against HEAD with a single shim
|
||||||
|
(`SetPlaybackFinishedHandler` gained a `srcErr error` parameter), which
|
||||||
|
makes "did the backend fix cause this" a ten-minute question instead of
|
||||||
|
a full checkout.
|
||||||
|
|||||||
@@ -22,6 +22,24 @@ export class SeekBar extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private seekValue: number = 0;
|
private seekValue: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user is dragging the thumb right now.
|
||||||
|
*
|
||||||
|
* It is `@state` rather than a plain field because `updated()` owns
|
||||||
|
* the interval and only reactive state brings `updated()` round. A
|
||||||
|
* bare `stopProgress()` in the input handler mutated nothing, so
|
||||||
|
* nothing re-rendered, so the tail of `updated()` that restarts the
|
||||||
|
* interval never ran — and the only things that could restart it
|
||||||
|
* were a `change` event or the next backend report. Any `input`
|
||||||
|
* without a committed `change` therefore 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, which on a phone are
|
||||||
|
* ordinary gestures. While playing, the 1 Hz report papered over it
|
||||||
|
* within a second; with reports not arriving it was permanent.
|
||||||
|
*/
|
||||||
|
@state()
|
||||||
|
private dragging: boolean = false;
|
||||||
|
|
||||||
/** Whether the right-hand clock shows time remaining or total. */
|
/** Whether the right-hand clock shows time remaining or total. */
|
||||||
@state()
|
@state()
|
||||||
private showRemaining: boolean = true;
|
private showRemaining: boolean = true;
|
||||||
@@ -133,6 +151,7 @@ export class SeekBar extends LitElement {
|
|||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.stopProgress();
|
this.stopProgress();
|
||||||
|
this.endDrag();
|
||||||
}
|
}
|
||||||
|
|
||||||
override updated() {
|
override updated() {
|
||||||
@@ -154,18 +173,33 @@ export class SeekBar extends LitElement {
|
|||||||
// A report for a track that is no longer loaded is stale by
|
// A report for a track that is no longer loaded is stale by
|
||||||
// definition: the change id is the only thing that distinguishes
|
// definition: the change id is the only thing that distinguishes
|
||||||
// it, since the same file can play twice in a row.
|
// it, since the same file can play twice in a row.
|
||||||
|
//
|
||||||
|
// A report arriving mid-drag is deliberately *not* applied: the
|
||||||
|
// thumb belongs to the finger on it, and adopting a report once a
|
||||||
|
// second pulls it back out from under them. The seq is left
|
||||||
|
// unrecorded too, so the first report after the drag still counts
|
||||||
|
// as fresh.
|
||||||
const position = this.player.position;
|
const position = this.player.position;
|
||||||
const forThisTrack =
|
const forThisTrack =
|
||||||
position !== null && position.trackChangeId === currentChangeId;
|
position !== null && position.trackChangeId === currentChangeId;
|
||||||
|
|
||||||
if (position && forThisTrack && position.seq !== this.previousPositionSeq) {
|
if (
|
||||||
|
position &&
|
||||||
|
forThisTrack &&
|
||||||
|
!this.dragging &&
|
||||||
|
position.seq !== this.previousPositionSeq
|
||||||
|
) {
|
||||||
this.previousPositionSeq = position.seq;
|
this.previousPositionSeq = position.seq;
|
||||||
this.seekValue = position.positionSeconds;
|
this.seekValue = position.positionSeconds;
|
||||||
this.stopProgress();
|
this.stopProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start/stop progress interval based on playback state
|
// One owner for the interval, and this is it. Every other place
|
||||||
if (this.isPlaying && this.hasTrack) {
|
// that wants it started or stopped says so by changing state that
|
||||||
|
// brings us back here, so the timer cannot be left running by a
|
||||||
|
// path that forgot to stop it or stopped by a path that forgot to
|
||||||
|
// start it again.
|
||||||
|
if (this.isPlaying && this.hasTrack && !this.dragging) {
|
||||||
this.startProgress();
|
this.startProgress();
|
||||||
} else {
|
} else {
|
||||||
this.stopProgress();
|
this.stopProgress();
|
||||||
@@ -210,18 +244,48 @@ export class SeekBar extends LitElement {
|
|||||||
|
|
||||||
private handleChange(e: Event) {
|
private handleChange(e: Event) {
|
||||||
const newSeekVal = (e.target as WaSlider).value;
|
const newSeekVal = (e.target as WaSlider).value;
|
||||||
|
this.endDrag();
|
||||||
this.setSeekValue(newSeekVal);
|
this.setSeekValue(newSeekVal);
|
||||||
this.player.seek(newSeekVal);
|
this.player.seek(newSeekVal);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isPlaying) {
|
/**
|
||||||
this.startProgress();
|
* The user is moving the thumb.
|
||||||
|
*
|
||||||
|
* This only records that fact; `updated()` decides what it means for
|
||||||
|
* the interval. `seekValue` follows the slider so the clocks track
|
||||||
|
* the thumb during the drag rather than jumping when it is released.
|
||||||
|
*/
|
||||||
|
private handleInput(e: Event) {
|
||||||
|
this.setSeekValue((e.target as WaSlider).value);
|
||||||
|
|
||||||
|
if (this.dragging) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.dragging = true;
|
||||||
|
|
||||||
|
// A drag that never commits must not strand the flag, or this fix
|
||||||
|
// turns a stall of up to one second into a permanent one -- which
|
||||||
|
// is the failure it exists to remove. `change` is the ordinary
|
||||||
|
// end; these are the ones that are not, and they are on the
|
||||||
|
// document because the pointer is routinely released outside the
|
||||||
|
// element it started in. A drag's listeners belong to the drag,
|
||||||
|
// so they go on with it and come off with it.
|
||||||
|
document.addEventListener('pointerup', this.endDrag);
|
||||||
|
document.addEventListener('pointercancel', this.endDrag);
|
||||||
|
document.addEventListener('touchend', this.endDrag);
|
||||||
|
document.addEventListener('touchcancel', this.endDrag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stops progress while user is dragging the thumb
|
private endDrag = () => {
|
||||||
private handleInput() {
|
document.removeEventListener('pointerup', this.endDrag);
|
||||||
this.stopProgress();
|
document.removeEventListener('pointercancel', this.endDrag);
|
||||||
}
|
document.removeEventListener('touchend', this.endDrag);
|
||||||
|
document.removeEventListener('touchcancel', this.endDrag);
|
||||||
|
|
||||||
|
this.dragging = false;
|
||||||
|
};
|
||||||
|
|
||||||
private setSeekValue(val: number) {
|
private setSeekValue(val: number) {
|
||||||
if (val < 0) val = 0;
|
if (val < 0) val = 0;
|
||||||
|
|||||||
@@ -397,6 +397,111 @@ describe('<seek-bar>', () => {
|
|||||||
expect(lastArgs('player.Player.Seek')).toEqual([42]);
|
expect(lastArgs('player.Player.Seek')).toEqual([42]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #164. `handleInput` used to call `stopProgress()` and mutate 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` or the next backend report could bring
|
||||||
|
// it back -- so an `input` that never commits froze the clock, which
|
||||||
|
// on a touch device is an ordinary cancelled gesture. With no
|
||||||
|
// reports arriving, that is permanent.
|
||||||
|
it('keeps ticking after a drag that never commits', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const el = await fixture('seek-bar');
|
||||||
|
|
||||||
|
emit(Events.TrackChanged, TRACK);
|
||||||
|
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// A touch lands on the track and is then cancelled: `input`, and
|
||||||
|
// no `change` ever follows.
|
||||||
|
const slider = shadow<HTMLElement & { value: number }>(el, 'wa-slider');
|
||||||
|
|
||||||
|
if (slider) slider.value = 20;
|
||||||
|
|
||||||
|
slider?.dispatchEvent(new Event('input'));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
document.dispatchEvent(new Event('pointerup'));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:23');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other half of the same fix: while the thumb is held, a report
|
||||||
|
// arriving once a second used to overwrite `seekValue` and pull it
|
||||||
|
// back out from under the finger.
|
||||||
|
it('leaves the thumb where the finger is while a drag is live', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const el = await fixture('seek-bar');
|
||||||
|
|
||||||
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 20 });
|
||||||
|
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const slider = shadow<HTMLElement & { value: number }>(el, 'wa-slider');
|
||||||
|
|
||||||
|
if (slider) slider.value = 60;
|
||||||
|
|
||||||
|
slider?.dispatchEvent(new Event('input'));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
emit(Events.PlaybackPositionChanged, {
|
||||||
|
positionSeconds: 4,
|
||||||
|
trackLength: 90,
|
||||||
|
trackChangeId: 20,
|
||||||
|
seq: 7,
|
||||||
|
playing: true,
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(text(el, '[data-testid="elapsed-time"]')).toBe('01:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
// And the drag must not hold the interval hostage once it ends: the
|
||||||
|
// report that was skipped mid-drag is not recorded as seen, so the
|
||||||
|
// next one is still fresh and is applied.
|
||||||
|
it('takes the backend back as the authority once the drag commits', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const el = await fixture('seek-bar');
|
||||||
|
|
||||||
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 21 });
|
||||||
|
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const slider = shadow<HTMLElement & { value: number }>(el, 'wa-slider');
|
||||||
|
|
||||||
|
if (slider) slider.value = 60;
|
||||||
|
|
||||||
|
slider?.dispatchEvent(new Event('input'));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
slider?.dispatchEvent(new Event('change'));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
emit(Events.PlaybackPositionChanged, {
|
||||||
|
positionSeconds: 61,
|
||||||
|
trackLength: 90,
|
||||||
|
trackChangeId: 21,
|
||||||
|
seq: 9,
|
||||||
|
playing: true,
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(text(el, '[data-testid="elapsed-time"]')).toBe('01:01');
|
||||||
|
});
|
||||||
|
|
||||||
it('bounds the slider by the track length', async () => {
|
it('bounds the slider by the track length', async () => {
|
||||||
const el = await fixture('seek-bar');
|
const el = await fixture('seek-bar');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user