Commit Graph
2 Commits
Author SHA1 Message Date
logan 29feb4b94b feat(android): the touch model reaches the other three lists
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m33s
CI / e2e (pull_request) Successful in 9m20s
Plan 019 phases 3 and 4, which finish #63. The queue panel and both
playlist detail views get tap-to-play and hold-to-select; the playlist
views get swipe-to-queue as well.

Phase 3 was not the pure wiring the plan expected, in two places.

A tap on a queue row plays that position. Copying track-list's tap --
which sets the queue to the list the row is in -- would rebuild the
queue from the queue, discarding its source, its shuffle order and
anything inserted by hand. It reads as a no-op and is not one.

And the queue panel has no swipe, deliberately. A right swipe means add
to the queue everywhere else it exists, and a queue row is already in
the queue; the only thing it could mean there is remove, which is the
same gesture with the opposite effect one screen away. Removing a queue
row is on the row, on its sheet since #60, and now on its selection
bar. The assertion is that its rows do not opt in.

The reveal became utils/swipe-to-queue.ts rather than being copied into
three lists, keyed on a data-swipe attribute so one stylesheet carries
the touch-action half of the device fix to rows that are called two
different things.

Phase 4 was already true and is now asserted: a claimed tap has its
click swallowed, so an explore-link inside a row never sees one and
tap-to-play wins with no rule of its own. Its test was vacuous when
written -- the tap helper sent no click, so there was nothing to
swallow -- which also weakened phase 1's. It sends one now.

Escape leaves selection mode, from selection-bar rather than from each
of the four hosts, since that element exists only while the mode does.
The platform's back gesture deliberately does not reach it: the shell
owns the history stack and four lists reaching for history is four
stacks. That is #200.

Verified on the reference phone: a queue row taps to its own index and
refuses a swipe, a playlist row queues on a swipe and plays its
playlist on a tap, and a hold raises the bar without the menu.

Closes #63
2026-08-22 01:41:21 -04:00
logan 76e1c444cc feat(android): tap to play, hold to select
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m41s
Phase 1 of #63, and the design the issue asks for as one piece is
.planning/plans/active/019-android-touch-model.md.

**A finger has no second button and no modifier keys**, so the primary
action has to be the primary gesture: tap plays the row, and the hold
that opened a context menu now enters selection mode with that row
selected.

Three decisions in it, and two diverge from the report.

**The predicate is the pointer, not the platform or the viewport.**
`pointerType === 'touch'`, per event, which is already how long-press.ts
decided and is the only such test in the frontend. This is #64's rule --
named after the capability -- and it carries #64's warning: keyed on a
width, an Android *tablet* at 600px gets click-selects/double-click-plays
on a touchscreen, which is the inversion this issue exists to fix, on
the platform it exists for. A touchscreen laptop cannot be described by
a width at all. Per event, a mouse keeps desktop semantics on the very
same row, and there is no second declaration of what a phone does.

**There is no double-tap, and the number is why.** The report asks for
single tap to play *and* double tap for the menu. Those cannot both be
honoured: the first tap of a double tap is indistinguishable from a
single tap until the interval expires, so "tap plays" becomes "tap
waits". Measured on the device, the play command to TrackChanged is
155/123/85/56/91 ms -- median ~100 -- and the app's own
DOUBLE_CLICK_GRACE_MS is 250. That is 3.5x the primary interaction,
250ms of it spent deliberately doing nothing, on every track anyone
plays, to reach a menu the hold already reaches. So the menu and the
selection action bar are the same surface, which is also the platform's
convention and removes a concept rather than adding one.

**Tap-to-play and selection mode ship together**, because splitting
them is a regression dressed as an increment: a touch user selects by
tapping today and acts through the long-press menu, so moving tap to
play on its own would leave a window with no way to select forty tracks
at all.

**What lets this reassign the hold without touching one of the fourteen
context menus**: the layer announces `yj-tap` / `yj-long-press`
(composed, cancelable) and acts on nothing. A component claims one with
preventDefault. An **unclaimed long press still becomes a
`contextmenu`**, so the card grids, Explore, the playlist rows and
every other menu behave exactly as they did, and only lists that opt in
get selection mode. An unclaimed *tap* does nothing at all and the
click follows normally, which is what leaves every button in the app
alone -- only a claimed tap has its click swallowed, or playing a track
would also select it.

**And the device found the one thing no browser tier can see.**
Chrome 113's Android WebView fires its own `contextmenu` on a long
press. long-press.ts stood down when a trusted one arrived, which was
right while both paths ended in a context menu; they no longer do, so
standing down means the gesture silently does the *old* thing.
Measured, before the fix, holding a track row:

    {"log":["contextmenu isTrusted=true"],
     "state":{"bar":null,"menuActive":true,"selected":1}}

`yj-long-press` was never announced, the menu opened, and all 26 tests
passed -- dispatched pointer events do not make a browser synthesise
one. So the native event is a **trigger, not a competitor**: the
gesture is announced from it and only a claim suppresses it. Unclaimed
it propagates untouched, which is the same "browser wins" outcome
reached by asking instead of assuming.

The tier could not find that and can hold it, because this module has
always told its own events apart by identity rather than isTrusted, so
an untrusted one from a test takes exactly the browser's path.

Verified on the device by *performing* the gestures rather than
describing the page -- `adb shell input tap` and `input swipe x y x y
700` reach the WebView as real pointer events, which is new here and is
written down in the plan with the pixel mapping. Tap plays; a hold
raises the bar with one selected and no menu; a tap toggles to two,
back to one, and the mode ends with the last row; an album card still
opens its context menu.

29 new tests. The e2e spec is rewritten to assert **both** halves --
the row selects, and a card elsewhere still opens the real menu --
because a spec that only checked the row would pass on a build that had
silently broken the other thirteen.

Phases 2-4 (swipe to queue, the other three surfaces, and what #67
inherits) are in the plan and not in this commit.
2026-08-22 00:23:33 -04:00