Compare commits

...
4 Commits
Author SHA1 Message Date
logan b1cdef8769 docs: record what a phone said that no tier could
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 5s
CI / e2e (push) Successful in 6m10s
CI / check (push) Successful in 3m28s
The first device run of the published APK, and the first runtime
evidence any of the Android work has ever had -- A4 shipped entirely
reasoned from source.

It confirms A4 whole: playback survives the screen locking, and the
transport notification appears with cover art, which settles four
open questions at once (the service starts, the permission was granted
and the notification is visible, the lock screen picks up the session,
and art decoded from a MANAGE_EXTERNAL_STORAGE path by a service is
readable -- the one nobody could argue from documentation).

It also found the two faults fixed in the preceding commits, and the
lesson worth keeping is why *those two*: both are things the platform
adds rather than things the app draws. So the skill's Android tier now
says to ask a device about system bars, the back gesture, focus and
audio interruptions, permissions and the keyboard -- and not about
layout, which the other five tiers already cover.
2026-08-17 02:02:10 -04:00
logan d661836347 fix(android): keep the app out from under the system bars
Reported from the first device run: the playback controls are off
screen. `targetSdk 35` is Android 15, which lays every app out
edge-to-edge and ignores the deprecated `statusBarColor` and
`navigationBarColor` the scaffold's theme still sets -- so a
`match_parent` WebView draws the page's bottom band, which on a phone
is the transport *and* the tab bar, underneath the gesture bar.

`applyWindowInsets()` pads the container by
`systemBars | displayCutout | ime` and returns the insets rather than
consuming them, so the WebView is laid out inside them. The keyboard is
in the mask because a search box the keyboard covers is the same bug
one surface over.

The window background goes black to match the app's own default ramp:
that padding is what shows through, and a band of the scaffold's
blue-grey above and below reads as the app failing to fill the screen.

No tier we have can see this class of fault -- a browser viewport has
no system bars, so `phone-shell.spec.ts` at 390x844 renders a shell
that fits at the moment the device is clipping it. Verified only as far
as the APK building; the insets need the next build on a phone.
2026-08-17 02:02:10 -04:00
logan 28eecf0a97 fix(ui): the Android back button had nowhere to go
Reported from the first device run: back does not navigate back in the
app. The scaffold's `MainActivity.onBackPressed` asks
`webView.canGoBack()` and finishes the activity otherwise -- and this
app had never touched `history`, so that was false at every depth and
back quit from anywhere.

The fix is here rather than in Java, because the mechanism the scaffold
already uses is the one we were failing to feed: a navigation is a
history entry now, and `popstate` replays it. Nothing on the Android
side changes, and the behaviour becomes assertable in a browser with
`page.goBack()` instead of only on a phone.

The entry keeps the same URL -- the app has no routes, and a path a
reload cannot resolve is worse than none -- and carries the destination
in its state.

Two rules keep the stacks from disagreeing. The first navigation
*replaces* the launch entry rather than pushing one, or every launch
costs a back press before the app will close. And the in-app back
buttons go through `history.back()` rather than popping a stack of
their own: `navStack` is deleted, not kept alongside, because two
stacks is precisely how a detail view's own button and the phone's
gesture come to disagree about how far one press goes. The third spec
pins that invariant.
2026-08-17 02:01:57 -04:00
logan e8690476bd feat(ui): long-press opens the menus a right-click opens
Every context menu in the app opens from a `contextmenu` event, bound
three different ways across six components -- delegated on a
virtualizer, per row, per card. A phone has no right-click, so a phone
reached none of them (plan 016 B2 phase 3).

This is one document-capture listener installed once from `index.ts`,
not six components' worth of touch handling: a touch that holds still
for 500ms dispatches a synthetic `contextmenu` at the touch point, and
every existing handler runs unchanged. A seam no component has to opt
into is one no future component can forget.

Four details are load-bearing, each a way the obvious version fails.
The target is `composedPath()[0]`, not `elementFromPoint`, which stops
at the outermost shadow host -- every menu here is bound inside one, so
a host-targeted event reaches a delegated listener and no per-row one.
A browser that fires its own long-press `contextmenu` (Chromium does;
WebKit and the Android WebView vary) wins, and ours is told from theirs
by identity rather than `isTrusted`: `isTrusted` works in the app and
is untestable, which would leave the suppression path as the one thing
with no coverage. And the click ending the gesture is swallowed, keyed
on the gesture rather than a time window, or the first tap on the menu
it just opened is eaten too.

The e2e spec presses `.track-row`, not `[role="row"]`: the column
header is a row too, and it is the first one -- a press on it is
correctly ignored, which reads exactly like the gesture not working.
2026-08-17 02:01:46 -04:00
11 changed files with 997 additions and 32 deletions
@@ -286,3 +286,27 @@ Related, and it will bite once: the launcher activity is
resolves the leading dot against the *applicationId* and fails with a
class-not-found that reads like a broken build. Always the
fully-qualified form.
## What only a device can answer
The emulator cannot run this app (three separate reasons, none of them
ours — see plan 016), so the phone in someone's pocket is a tier, and
asking for it is cheap. The first run of it, on 2026-08-17, confirmed
the whole of A4 and found two faults **no other tier can see**:
- **The back gesture.** `MainActivity.onBackPressed` asks
`webView.canGoBack()`. Nothing in a desktop shell has a back gesture,
so no spec had ever called `page.goBack()` and the app had never
pushed a history entry — back quit from any depth. It is a history
entry per navigation now, which is also what made it assertable in the
browser tier (`e2e/specs/back-navigation.spec.ts`).
- **The safe area.** `targetSdk 35` forces edge-to-edge, so the
transport and the tab bar sat under the gesture bar. **A browser
viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will
keep passing on a build the device is clipping 48dp off. Insets are
handled in `applyWindowInsets()`.
So when asking for a device run, ask about what the platform *adds* —
system bars, the back gesture, focus and audio interruptions,
permission dialogs, the keyboard — not about what the app draws. The
drawing is what the other five tiers already cover.
+104
View File
@@ -3034,3 +3034,107 @@ reason this was caught is that the assertion about the probe ran before
the assertion about the export. A fixture built by string surgery on a
formatted constant needs to be whitespace-independent; it filters the
list now.
## Long-press is one document listener, and the header row is a row (2026-08-17)
Plan 016 B2 phase 3. A phone has no right-click, and every context menu
in this app opens from a `contextmenu` event — six components' worth,
bound three different ways (delegated on a virtualizer, per row, per
card). `frontend/src/utils/long-press.ts` is one document-capture
listener installed once from `index.ts`: a touch that holds still for
500 ms dispatches a synthetic `contextmenu` at the touch point, and
**every existing handler runs unchanged**. No component opted in, and
none can forget to.
Four things it has to get right, and each is a way the obvious version
fails:
- **The target is `composedPath()[0]`, not `elementFromPoint`**, which
stops at the outermost shadow host. Every menu here is bound inside
one, so a host-targeted event reaches a delegated listener and no
per-row one.
- **A browser that fires its own must win.** Chromium already dispatches
`contextmenu` on long-press; WebKit and the WebView vary. One arriving
during the press cancels ours; one arriving after ours is swallowed at
document capture.
- **Ours is told from theirs by identity** (a `WeakSet`), not by
`isTrusted`. `isTrusted` would work in the app and is untestable — no
test can dispatch a trusted event — so the suppression path would have
been the one thing with no coverage.
- **The click ending the gesture is swallowed**, keyed on the gesture
(cleared by the next `pointerdown`) rather than a time window, or a
quick tap on the menu that just opened is eaten too.
**What cost the time was the assertion, not the code.** The e2e spec
pressed `[role="row"]` — which is the *column header*, and it is the
first one. The gesture fired correctly, the header correctly ignored it,
and the failure looked exactly like a menu that would not open. Found by
probing the running app (`playwright-cli eval`, dispatching the same
pointer events and logging what saw the `contextmenu`), which showed the
event reaching the row's own listener with no menu behind it — i.e. the
handler was refusing it, not missing it. `.track-row` is the selector.
Verified by execution: 8 component tests (real browser, real shadow
boundary, real timings) and 2 e2e specs against the running app, twice
in a row. Not verified: any of it under a real finger on a real
WebView — the pointer events are dispatched, because neither Desktop
Chrome nor Desktop Safari has touch and there is no device tier.
## The first device run: A4 works, and two things only a phone could say (2026-08-17)
The published v1.5.0 APK, on a real phone, owner-reported. **This is the
first runtime evidence any of the Android work has ever had** — A4
shipped entirely reasoned from source.
**What holds.** Playback survives the screen locking. The MediaSession
notification appears in the status pane *with album art* — which
answers, in one observation, four of the open questions from plan 016:
the foreground service starts, POST_NOTIFICATIONS was granted and the
notification is visible, the session is picked up, and **cover art
decoded from a `MANAGE_EXTERNAL_STORAGE` path by a service is
readable**. The last was the one nobody could argue from documentation.
**Two bugs, and neither is visible from any tier we have.**
*Back did not navigate back.* The scaffold's
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
activity otherwise — and this app had never touched `history`, so that
was false at every depth and back quit from anywhere. The fix is in the
frontend, not in Java: a navigation is a `history` entry now
(`recordNavigation` in `index.ts`, same URL, the destination in the
entry's state) and `popstate` replays it with `_isBack`. The Java half
needs no change, because the mechanism it already uses is the one we
were failing to feed.
Two rules keep it honest. The **first** navigation replaces the launch
entry rather than pushing one, or every launch costs a back press before
the app will close. And the in-app back buttons go through
`history.back()` rather than popping a stack of their own — `navStack`
is **deleted**, not kept alongside, because two stacks is exactly how
the detail view's own button and the phone's gesture come to disagree
about how far back one press goes. `back-navigation.spec.ts` pins that
invariant.
*The transport was off screen.* **`targetSdk 35` is Android 15, which
lays every app out edge-to-edge**, ignores the deprecated
`statusBarColor`/`navigationBarColor` the theme still sets, and hands
the app a window the size of the screen. The WebView is `match_parent`,
so the page's bottom band — the transport, and on a phone the tab bar —
was drawn underneath the gesture bar. `applyWindowInsets()` pads the
container by `systemBars | displayCutout | ime` and returns the insets
rather than consuming them. The window background goes black to match
the app's own ramp, or the padding shows as a blue-grey band.
**Neither is findable in the browser tier, and that is the lesson worth
keeping**: a viewport has no system bars, so `phone-shell.spec.ts` at
390x844 renders a shell that fits perfectly while the device cuts 48dp
off the bottom — and `page.goBack()` was never called because nothing in
a desktop shell has a back gesture. The Android tier's own note says
failure there is invisible; this is the milder version, where the app
works and is simply wrong in ways only the platform can show you.
Verified by execution: the APK builds with the Java change; 3 e2e specs
cover the history behaviour, on Chromium locally and WebKit in CI.
Not verified: the insets themselves, which need the next APK on the
owner's phone. What to look for is one thing — the transport and the tab
bar clear of the gesture bar, and the header clear of the status bar.
@@ -315,8 +315,8 @@ places had to agree — `abiFilters`, the Makefile's `android:package`
anchor is what stops it also matching the fat APK's line. Adding the
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
**B2, the desktop shell.** Scope decided (below); **phases 1 and 2 are
done.**
**B2, the desktop shell.** Scope decided (below); **phases 1, 2 and 3
are done.**
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
`<bottom-nav>` is the primary navigation, and the shell fits 320px
@@ -326,15 +326,47 @@ done.**
composes the real transport components rather than copying them; and
it hides the bottom bar while it is up, so it carries its own queue
button.
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
listener, installed once from `index.ts`, which turns a 500 ms
stationary touch into a synthetic `contextmenu` at the touch point.
Every menu in the app opens from that event, so all six components
gained the gesture without one of them changing — which is the same
argument `ContextMenuController` rests on, one layer lower. The
details that are not obvious are in `NOTES.md` (2026-08-17); the one
worth repeating is that ours is told from the browser's own
long-press event by **identity**, not `isTrusted`, because a test
cannot dispatch a trusted event and that path would otherwise be the
only uncovered one.
What is left is the rest of the *interactions*: long-press for the
context menus that are right-click today, and the track list's
resizable columns, which are a pointer feature with no touch
equivalent. Neither is started.
What is left of B2 is the track list, whose resizable columns are a
pointer feature with no touch equivalent. Not started.
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
with all-files access, `tagwriter` can write in place.
### What the first device run answered (2026-08-17)
A4 **works**: playback survives the screen locking, and the transport
notification appears with cover art — which also settles the service's
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
the lock-screen session in one observation. Everything below in "what
none of section A answered" was written before this and is now answered
except the OEM permission-flow variance.
It also found two faults no browser tier can see, both fixed and both
awaiting the next APK for confirmation (`NOTES.md`, same date):
- **Back quit the app from any depth.** The scaffold asks
`webView.canGoBack()`; the frontend had never used `history`. A
navigation is a history entry now, and `navStack` is gone rather than
kept beside it.
- **The transport was under the gesture bar.** `targetSdk 35` is
edge-to-edge by force; `applyWindowInsets()` in `MainActivity` pads
by `systemBars | displayCutout | ime`.
The standing item is unchanged in kind: **B3 (tag writing) and the
permission flow still need a device**, and so does confirming these two.
### What none of section A answered
Nothing here has been observed on a device. The permission flow in
+47
View File
@@ -683,6 +683,27 @@ moment it is most needed is the likeliest moment loading one fails.
`first-run-wizard` and the startup chrome are eager for the ordinary
reason — they are the first paint.
**A navigation is a history entry, and that is the whole back stack.**
`index.ts` records each navigation with `pushState` (same URL — the app
has no routes, and a path a reload cannot resolve is worse than none)
and replays `popstate` with `_isBack`. It exists for Android, whose back
button is not a key the page can bind: the scaffold's
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
activity otherwise, so an app that never touched `history` quit from any
depth — which is what a device reported. Hooking the platform's own
mechanism rather than adding a JNI callback is also what makes it
testable in a browser (`page.goBack()`), and the Java half needed no
change at all.
Two rules hold it up. The **first** navigation *replaces* the launch
entry rather than pushing one, or every launch costs a back press before
the app will close. And the in-app back buttons (`navigate-back`, fired
by the detail views and `now-playing-view`) go through `history.back()`
rather than a stack of their own: the old `navStack` is **deleted**, not
kept beside it, because two stacks is precisely how a view's own back
button and the phone's gesture come to disagree about what one press
means.
**A primary view is cached, not unmounted.** `index.ts` keeps every
primary view in the DOM and toggles a `.view-hidden` class, because that
is what preserves `scrollTop` across navigation — so
@@ -838,6 +859,20 @@ against the real components:
moving focus without setting it leaves the highlight on whichever
item the mouse last touched.
**And a menu opens from a finger, through the event it already has.**
`utils/long-press.ts` is one document-capture listener installed once
from `index.ts`: a touch that holds still for 500 ms dispatches a
synthetic `contextmenu` at the touch point, so all six components that
bind one — delegated on a virtualizer, per row, per card — gained the
gesture without changing. The target is `composedPath()[0]` rather than
`elementFromPoint`, which stops at the outermost shadow host and so
reaches a delegated listener and no per-row one; a browser that fires
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
vary) wins, ours being told from theirs by **identity** rather than
`isTrusted`, since no test can dispatch a trusted event; and the click
that ends the gesture is swallowed, keyed on the gesture rather than on
a time window so the first tap on the menu it opened is not eaten too.
Three lists had no focused row to open a menu *from* — the queue panel
and both playlist detail views — and gained a roving tab stop through
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
@@ -1960,6 +1995,18 @@ like source** — it was generated once into a scratch directory and
copied across (plan 015), it carries one deliberate edit to its
`Taskfile.yml`, and only its output is gitignored. `build/ios/` is
still not carried and its `includes:` entry is still dropped.
**Its `MainActivity` owns the safe area, because `targetSdk 35` does
not leave that to the theme.** Android 15 lays every app out
edge-to-edge and ignores the `statusBarColor`/`navigationBarColor` the
scaffold's theme sets, and the WebView is `match_parent` — so the page's
bottom band, which on a phone is the transport *and* the tab bar, was
drawn under the gesture bar. `applyWindowInsets()` pads the container by
`systemBars | displayCutout | ime` and returns the insets rather than
consuming them; the window background is black to match the app's own
ramp, since that padding is what shows through. **No browser tier can
see this class of fault** — a viewport has no system bars, so the phone
specs render a shell that fits at the moment the device is clipping it.
`build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version.
@@ -31,9 +31,16 @@ import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.FileProvider;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.webkit.WebViewAssetLoader;
import org.json.JSONObject;
@@ -88,6 +95,10 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Before anything renders: the page is laid out inside the
// window, and on Android 15 the window is the whole screen.
applyWindowInsets();
// Initialize the native Go library
bridge = new WailsBridge(this);
bridge.initialize();
@@ -892,8 +903,61 @@ public class MainActivity extends AppCompatActivity {
}
}
/**
* Keep the web content inside the safe area.
*
* <p>targetSdk 35 is Android 15, which lays every app out
* edge-to-edge and ignores the {@code statusBarColor} and
* {@code navigationBarColor} this app's theme still sets. The
* WebView is {@code match_parent}, so the page's bottom band -- the
* transport and, on a phone, the tab bar -- was drawn underneath the
* gesture bar and reported from a device as "I can't see the
* playback controls, they seem to be off screen".
*
* <p>No web-tier test can see this: a browser viewport has no system
* bars, so the phone specs at 390x844 render a shell that fits
* while the device does not.
*
* <p>The insets are applied as padding and the window insets are
* returned rather than consumed, so the WebView is laid out inside
* them. {@code ime()} is in the mask because the same reasoning
* covers the keyboard: a focused search box that the keyboard
* covers is the same bug one surface over.
*/
private void applyWindowInsets() {
final View container = findViewById(R.id.main_container);
if (container == null) {
return;
}
ViewCompat.setOnApplyWindowInsetsListener(container, (view, windowInsets) -> {
Insets insets = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars()
| WindowInsetsCompat.Type.displayCutout()
| WindowInsetsCompat.Type.ime());
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
return windowInsets;
});
// The padded band shows the window background, which is dark
// (this app's own default ramp is black), so the system's icons
// have to be the light set or they vanish into it. The theme is
// DayNight and would otherwise ask for dark icons in light mode.
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
controller.setAppearanceLightStatusBars(false);
controller.setAppearanceLightNavigationBars(false);
}
@Override
public void onBackPressed() {
// The frontend records every navigation as a history entry, so
// this is the app's own back stack: `canGoBack()` is false only
// at the launch entry, which is where back should leave.
if (webView != null && webView.canGoBack()) {
webView.goBack();
} else {
@@ -2,7 +2,12 @@
<resources>
<color name="wails_blue">#3574D4</color>
<color name="wails_blue_dark">#2C5FB8</color>
<color name="wails_background">#1B2636</color>
<!-- The window background, which is what the launch screen shows and
what the system-bar padding leaves visible. Black rather than the
scaffold's blue-grey because this app's own default ramp is
black: a band of #1B2636 above and below it reads as the app
failing to fill the screen. -->
<color name="wails_background">#000000</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
</resources>
+92
View File
@@ -0,0 +1,92 @@
import { test, expect } from '../support/fixtures.js';
/**
* Back is the platform's, and the app has to have somewhere for it to
* go (reported from a device: "the Android back button does not
* navigate back in the app").
*
* The scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()`
* and finishes the activity otherwise. This app never touched
* `history`, so that was always false and back quit from any depth. A
* navigation is a history entry now, which is why this is assertable
* here at all: `page.goBack()` is the same `popstate` the phone's
* gesture produces, so the browser tier can answer a question that
* otherwise needs a device.
*
* What it cannot answer is whether Android's *gesture* reaches the
* WebView, which is between the OS and the scaffold.
*/
type Page = import('@playwright/test').Page;
const activeView = (page: Page) =>
page.getByTestId('main-content');
/**
* Open an artist's detail view, which is the deepest ordinary route.
*
* A library artist opens `explore-artist-details` -- the catalog panel
* standing in for a library one, as `explore-link.ts` describes -- and
* the view name follows the component, not the source of the click.
*/
async function openAnArtist(app: Page): Promise<void> {
await app.getByTestId('nav-artists').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
// A card, by the name on it: the grid is virtualized and positioned
// by transform, so a click at coordinates is a click at whatever
// happens to be there.
await app.locator('artists-view').getByText('Aurora Fields').first().click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-artist-details',
);
}
test.describe('the back gesture', () => {
test('leaves a detail view for the view it was opened from', async ({
app,
}) => {
await openAnArtist(app);
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
});
test('walks back through primary views, one press per navigation', async ({
app,
}) => {
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
// Forward is free once back works, and it is what proves the entry
// was restored rather than the view merely re-rendered.
await app.goForward();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
test('an in-app back button consumes exactly one entry', async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await openAnArtist(app);
// The detail view's own back button and the phone's gesture are the
// same press: if each popped its own stack, this would land two
// navigations back instead of one.
await app
.locator('explore-artist-details')
.getByRole('button', { name: 'Back to explore' })
.click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
});
});
+127
View File
@@ -0,0 +1,127 @@
import { test, expect } from '../support/fixtures.js';
/**
* Long-press is the touch route to a context menu (plan 016 B2 phase 3).
*
* The component tier proves the gesture in isolation, against markup it
* built itself. What it cannot prove is the half that made this one
* listener instead of six: that the synthetic event reaches the handler
* a *real* component bound — `track-list` delegates its `contextmenu`
* on the `lit-virtualizer` rather than binding one per row — and that
* the real `wa-popup` menu opens from it, which is a path with its own
* history of opening and then refusing to work (see
* `menu-keyboard.spec.ts`).
*
* The pointer events are dispatched rather than performed: this project
* runs Desktop Chrome and Desktop Safari, neither of which has touch,
* and a device tier does not exist. So this is honest about what it
* checks — the app's own listeners, on the app's own DOM, from the
* events a touch would produce — and not about a real finger.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** The track list's menu panel, or null while it is not rendered. */
const panel = (page: Page) =>
page.evaluate(() => {
const el = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
});
/**
* Press the first track row, optionally dragging partway through — the
* shape of a scroll that begins on a row, which must not open a menu.
*/
async function pressFirstRow(
page: Page,
opts: { driftY?: number } = {},
): Promise<void> {
await page.evaluate((drift) => {
// `.track-row`, not `[role="row"]`: the column header is a row too,
// and it is the *first* one -- a press on it is correctly ignored,
// which reads exactly like the gesture not working.
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to press');
const box = row.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
row.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
}, opts.driftY ?? 0);
}
test.describe('long-press opens the track menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
await expect.poll(() => panel(app)).toBeNull();
await pressFirstRow(app);
await expect
.poll(() => panel(app), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu', label: 'Track actions' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app))?.items).toBeGreaterThan(0);
});
test('does not open one for a press that turns into a scroll', async ({
app,
}) => {
await pressFirstRow(app, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await panel(app)).toBeNull();
});
});
+82 -25
View File
@@ -50,6 +50,7 @@ import '@store/theme-store';
// registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installLongPressContextMenu } from '@utils/long-press';
import {
hasTrackPayload,
getDragPayload,
@@ -64,6 +65,11 @@ setBasePath('/dist/webawesome');
// the session.
registerBundledIcons();
// The touch equivalent of a right-click, installed once for every menu
// in the app rather than per component. Harmless on a desktop: it acts
// on `pointerType === 'touch'` only.
installLongPressContextMenu();
// ---------------------------------------------------------------------------
// View caching navigation system
// ---------------------------------------------------------------------------
@@ -154,10 +160,6 @@ const viewCache = new Map<string, HTMLElement>();
let currentViewEl: HTMLElement | null = null;
let currentDetailEl: HTMLElement | null = null;
/** Navigation history stack for back-button support in detail views. */
const navStack: Array<{ view: string; [key: string]: any }> = [];
/** The current navigation detail (so we can push it onto the stack). */
let currentNavDetail: { view: string; [key: string]: any } = { view: 'home' };
const mainContent = document.getElementById('main-content');
@@ -190,6 +192,71 @@ document.addEventListener('navigate', (e: Event) => {
void handleNavigate((e as CustomEvent).detail);
});
// ---------------------------------------------------------------------------
// The platform's back gesture
// ---------------------------------------------------------------------------
// Android's back button is not a keystroke the page can bind: the
// scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()` and
// otherwise finishes the activity. This app never touched `history`, so
// that was always false and back quit the app from any depth -- reported
// from a device as "back does not navigate back".
//
// So a navigation is a history entry, and back is `popstate`. It hooks
// the platform's own mechanism rather than a JNI callback of our own,
// which is the same reason `events.ts` hooks the runtime's transport:
// the Java half needs no change, and the behaviour is testable in a
// browser (`page.goBack()`) instead of only on a phone.
//
// Two rules keep the two stacks from disagreeing. A navigation that
// *came from* history pushes nothing (`_isBack`), or going back would
// deepen the stack it is unwinding. And the in-app back buttons --
// `navigate-back`, which the detail views and `now-playing-view` fire --
// go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries.
/** The navigation an entry stands for. `undefined` on the entry that
* predates the app's own routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any } };
/** Whether the app's first navigation has been recorded. It *replaces*
* the launch entry rather than pushing, or every launch would cost one
* back press before the app would exit. */
let historyStarted = false;
/** How many entries this session has pushed beyond that first one --
* i.e. how deep back can go while staying inside the app. */
let pushedEntries = 0;
function recordNavigation(detail: { view: string; [key: string]: any }): void {
// `_isBack` is bookkeeping, not destination: keeping it in the entry
// would make a replayed navigation claim to be a back-navigation.
const { _isBack: _ignored, ...nav } = detail;
const state: NavState = { yjNav: nav };
// Same URL, deliberately: the app has no routes, and a path a
// reload cannot resolve is worse than no path at all.
if (historyStarted) {
history.pushState(state, '');
pushedEntries += 1;
} else {
history.replaceState(state, '');
historyStarted = true;
}
}
window.addEventListener('popstate', (e: PopStateEvent) => {
const nav = (e.state as NavState | null)?.yjNav;
// Before the app's first navigation, or an entry somebody else
// pushed: nothing to restore, and the activity should be free to
// finish.
if (!nav) return;
pushedEntries = Math.max(0, pushedEntries - 1);
void handleNavigate({ ...nav, _isBack: true });
});
async function handleNavigate(
detail: { view: string; [key: string]: any },
): Promise<void> {
@@ -199,6 +266,8 @@ async function handleNavigate(
const seq = ++navSeq;
if (!detail._isBack) recordNavigation(detail);
// Bookkeeping stays synchronous with the click: the search box's
// scope and the active-view attribute describe the navigation that
// was *asked for*, and are what the rest of the app and the e2e
@@ -212,9 +281,6 @@ async function handleNavigate(
// --- Primary (cacheable) views ----------------------------------------
if (view in VIEW_TAGS) {
// Navigating to a primary view clears the history stack.
navStack.length = 0;
// Remove any active detail view first
if (currentDetailEl) {
deactivateView(currentDetailEl);
@@ -247,7 +313,6 @@ async function handleNavigate(
// the way out. Either way this is the call that starts it.
activateView(target);
currentViewEl = target;
currentNavDetail = { view };
return;
}
@@ -256,12 +321,6 @@ async function handleNavigate(
if (seq !== navSeq) return;
// --- Detail (ephemeral) views -----------------------------------------
// Push the current view onto the nav stack before switching
// (unless this is a back-navigation, which already popped).
if (!detail._isBack) {
navStack.push({ ...currentNavDetail });
}
// Hide the current primary view
if (currentViewEl) {
currentViewEl.classList.add('view-hidden');
@@ -274,8 +333,6 @@ async function handleNavigate(
currentDetailEl = null;
}
currentNavDetail = { ...detail };
switch (view) {
case 'artist-details': {
const { artistId, artistName } = detail;
@@ -419,16 +476,16 @@ function schedule(fn: () => void): void {
setTimeout(fn, 200);
}
// Navigate-back: pop the nav stack and re-dispatch as a regular navigate.
// Navigate-back: the in-app back buttons, which are the same press as
// the phone's. It goes through the history rather than a stack of its
// own, so one press is one entry however it arrived -- two stacks is
// how a detail view's own button and the back gesture come to disagree.
//
// At the root there is nothing of ours to go back to, and going back
// anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it.
document.addEventListener('navigate-back', () => {
const prev = navStack.pop();
if (prev) {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { ...prev, _isBack: true },
}));
}
if (pushedEntries > 0) history.back();
});
// Navigate to the user's configured launch page. Falls back to 'home'
+201
View File
@@ -0,0 +1,201 @@
/**
* Long-press as the touch equivalent of a right-click (plan 016 B2,
* phase 3).
*
* Every context menu in the app opens from a `contextmenu` event —
* `track-list` and `queue-panel` delegate one on their virtualizer,
* the card grids and both playlist detail views bind one per row, and
* `explore-artist-details` binds three. A phone has no right-click, so
* a phone reached none of them.
*
* **This is one document listener, not six components' worth of touch
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
* synthetic `contextmenu` at the touch point on the element the touch
* actually landed on, and every existing handler — delegated or
* per-row, in any shadow root — runs unchanged. Six implementations of
* a gesture is exactly the fault `ContextMenuController` exists to
* prevent, and a seam that needs no component to opt in cannot be
* forgotten by the next component.
*
* Three things about it are load-bearing.
*
* **The target comes from `composedPath()[0]`, not from
* `elementFromPoint`**, which stops at the outermost shadow host: every
* menu in this app is bound inside one, so a synthetic event dispatched
* on the host reaches a delegated listener and no per-row one.
*
* **A browser that already does this must win.** Chromium fires a
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
* vary. So one arriving during the press cancels ours, and one arriving
* just after ours is swallowed at document capture — where nothing else
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
* of the events this module made) rather than by `isTrusted`, so the
* suppressor cannot eat the event it exists to deliver, the rule holds
* for anything else in the app that synthesises one, and a test can
* stand in for a browser that fires its own.
*
* **The click that ends the gesture is swallowed.** A row's click
* selects, and a card's plays; without this, opening a menu also
* activates the thing under it. It is keyed on the gesture (cleared by
* the next `pointerdown`) rather than on a time window, so a quick tap
* on the menu that just opened is not eaten too.
*/
/** How long a press must hold still to mean "menu". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`: see
* the note above. */
const ours = new WeakSet<Event>();
/**
* Install the gesture. Idempotent; returns the uninstaller (which the
* tests use — the app installs once and never removes it).
*/
export function installLongPressContextMenu(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** We opened a menu, and the click ending that gesture is not a
* click on anything. */
let swallowClick = false;
/** We dispatched one, so a trusted one arriving now is a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
};
const fire = (): void => {
timer = null;
const el = target;
target = null;
if (nativeSeen || !el) return;
justFired = true;
swallowClick = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
timer = setTimeout(fire, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
if (drifted) cancel();
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser got there first, so stand down rather than
// opening the same menu twice.
nativeSeen = true;
cancel();
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', cancel, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
// A scroll started by something other than the finger (momentum, a
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', cancel, { capture: true, passive: true });
uninstall = () => {
cancel();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', cancel, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('scroll', cancel, opts);
uninstall = null;
};
return uninstall;
}
+212
View File
@@ -0,0 +1,212 @@
/**
* Long-press as the touch route to a context menu (plan 016 B2 phase 3).
*
* These run in a real browser with real event dispatch, which is the
* only place the two things that make this hard are true: the synthetic
* event has to cross a shadow boundary to reach the listener a
* component actually bound, and the suppressors have to tell a trusted
* event from ours at document capture without eating the one they exist
* to deliver.
*
* The timings are real rather than faked, because the thing under test
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
* that would also have to fake the pointer events.
*/
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
import {
installLongPressContextMenu,
LONG_PRESS_MS,
MOVE_TOLERANCE_PX,
} from '@utils/long-press';
/** A press that has certainly resolved, either way. */
const HELD = LONG_PRESS_MS + 120;
/** A press that has certainly not. */
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
let host: HTMLElement;
let inner: HTMLElement;
/** A row inside a shadow root, which is where every menu in this app
* is bound — an element in the light DOM would pass a weaker test. */
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
const el = document.createElement('div');
const root = el.attachShadow({ mode: 'open' });
const row = document.createElement('div');
row.textContent = 'a track';
root.append(row);
document.body.append(el);
return { host: el, inner: row };
}
function press(
el: EventTarget,
type: string,
init: PointerEventInit = {},
): void {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* Record every `contextmenu` that reaches the listener, *as the
* listener sees it*.
*
* `target` is retargeted for the scope reading it, so an assertion made
* after dispatch has finished reports the shadow host however the event
* was dispatched - which is the same answer a broken implementation
* gives. It has to be read from inside the handler, where the component
* reads it.
*/
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Every real handler does this; the gesture must work anyway.
e.stopPropagation();
seen.push({ event: e as MouseEvent, target: e.target });
});
return seen;
}
describe('long-press opens a context menu', () => {
beforeEach(() => {
uninstall = installLongPressContextMenu();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('dispatches one at the touch point, on the element touched', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(HELD);
expect(seen).toHaveLength(1);
expect(seen[0]?.event.clientX).toBe(40);
expect(seen[0]?.event.clientY).toBe(60);
// Dispatched on the row itself, not on its shadow host - which is
// the difference between a per-row handler firing and only a
// delegated one firing.
expect(seen[0]?.target).toBe(inner);
});
it('is cancelled by a press that moves', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40 + MOVE_TOLERANCE_PX + 5,
clientY: 60,
});
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('tolerates the jitter a finger cannot help', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
await wait(HELD);
expect(seen).toHaveLength(1);
});
it('is cancelled by lifting early, and by a scroll', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
await wait(HELD);
expect(seen).toHaveLength(0);
press(inner, 'pointerdown');
press(inner, 'pointercancel');
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('ignores a mouse, which has a right button of its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('swallows the click that ends the gesture, and only that one', async () => {
let clicks = 0;
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
// The next tap is a tap: on a phone that is the user choosing an
// item in the menu that just opened, so eating it would make the
// gesture useless.
press(inner, 'pointerdown');
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('stands down where the browser fires its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
// Chromium does this itself on touch; WebKit and the Android
// WebView vary, which is the whole reason both halves exist. A
// test cannot dispatch a *trusted* event, which is why the module
// tells its own apart by identity rather than by `isTrusted`.
inner.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
}),
);
await wait(HELD);
// One menu: the browser's. Not two.
expect(seen).toHaveLength(1);
});
});