Files
yellowjacket/frontend/src/utils/selection-controller.ts
T
logan 76e1c444cc
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m41s
feat(android): tap to play, hold to select
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

380 lines
12 KiB
TypeScript

import type { ReactiveController, ReactiveControllerHost } from 'lit';
/**
* Host interface for components using the SelectionController.
* The host must provide a way to look up item keys by index and
* report the total item count.
*/
export interface SelectionHost extends ReactiveControllerHost {
getItemKey(index: number): string | undefined;
getItemCount(): number;
onSelectionChanged?(): void;
}
/**
* Reusable selection controller that manages multi-select state
* with click, Ctrl+click, and Shift+click semantics.
*/
export class SelectionController implements ReactiveController {
private host: SelectionHost;
private _selectedItems: Set<string> = new Set();
private lastSelectedIndex: number | null = null;
private _mode = false;
/**
* Whether the list is in *selection mode* (plan 019, #63).
*
* A finger has no modifier keys, so the ctrl/shift semantics this
* controller was written for cannot be expressed by touch at all.
* Selection mode is the platform's answer: a long press enters it,
* and while it is on, a tap toggles a row instead of playing it.
*
* It is a flag *here* rather than a fifth concept beside the
* controller because all four surfaces that select
* (`track-list`, `queue-panel` and both playlist detail views)
* already share this class -- so "is this list selecting" has one
* answer per list, in the object that already owns the selection
* it would otherwise contradict.
*
* A mouse never sets it. Desktop selection is unchanged and stays
* modeless, which is what `handleItemClick` still implements.
*/
get selectionMode(): boolean {
return this._mode;
}
constructor(host: SelectionHost) {
this.host = host;
host.addController(this);
}
hostConnected(): void {
// No-op; state is component-local.
}
hostDisconnected(): void {
// No-op.
}
// =================================================================
// STATE ACCESSORS
// =================================================================
/** The current set of selected item keys. */
get selectedItems(): ReadonlySet<string> {
return this._selectedItems;
}
/** Whether any items are currently selected. */
get hasSelection(): boolean {
return this._selectedItems.size > 0;
}
/** Number of selected items. */
get selectionCount(): number {
return this._selectedItems.size;
}
/** Check whether a specific key is selected. */
isSelected(key: string): boolean {
return this._selectedItems.has(key);
}
// =================================================================
// ACTIONS
// =================================================================
/**
* Handle a click on an item row. Supports plain click (replace
* selection), Ctrl/Cmd+click (toggle), Shift+click (range), and
* Ctrl+Shift+click (add range to existing selection).
*/
handleItemClick(
e: MouseEvent,
key: string,
index: number,
): void {
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (isShift && this.lastSelectedIndex !== null) {
const range = this.selectRange(
this.lastSelectedIndex,
index,
);
// Both Shift and Ctrl+Shift add the range to the
// existing selection.
const next = new Set(this._selectedItems);
for (const path of range) {
next.add(path);
}
this._selectedItems = next;
// Don't update anchor on shift-click so the user can
// adjust the range endpoint with another shift-click.
} else if (isCtrl) {
const next = new Set(this._selectedItems);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
this._selectedItems = next;
this.lastSelectedIndex = index;
} else {
this._selectedItems = new Set([key]);
this.lastSelectedIndex = index;
}
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Handle a right-click (context menu) on an item. If the clicked
* item is not already selected, replace the selection with just
* that item. Otherwise preserve the existing multi-selection.
*/
handleContextMenu(key: string): void {
if (!this._selectedItems.has(key)) {
this._selectedItems = new Set([key]);
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
}
/** Whether `next` holds exactly the currently selected keys. */
private sameMembership(next: ReadonlySet<string>): boolean {
if (next.size !== this._selectedItems.size) return false;
for (const key of next) {
if (!this._selectedItems.has(key)) return false;
}
return true;
}
/**
* Enter selection mode with `key` selected.
*
* The row the gesture was made on is selected, rather than the
* mode opening empty: a long press is a statement about *that*
* row, and an action bar with nothing in it is a mode the user has
* to make a second gesture to escape.
*/
enterSelectionMode(key: string, index: number): void {
this._mode = true;
this._selectedItems = new Set([key]);
this.lastSelectedIndex = index;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Toggle one row, and leave the mode when the last one goes.
*
* Deselecting everything is how Android's own list surfaces exit
* selection mode, and it matters more here than convention: the
* mode changes what a tap *means*, so a mode with an empty
* selection is a list where tapping does nothing and nothing on
* screen says why.
*/
toggleInMode(key: string, index: number): void {
const next = new Set(this._selectedItems);
if (next.has(key)) next.delete(key);
else next.add(key);
this._selectedItems = next;
this.lastSelectedIndex = index;
if (next.size === 0) this._mode = false;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Leave selection mode, dropping the selection with it. */
exitSelectionMode(): void {
if (!this._mode && this._selectedItems.size === 0) return;
this._mode = false;
this._selectedItems = new Set();
this.lastSelectedIndex = null;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Clear the entire selection. */
clear(): void {
// The mode goes with it: every caller of this means "the
// selection is no longer meaningful", and a mode outliving the
// selection it was showing is the empty-mode trap above.
this._mode = false;
if (this._selectedItems.size === 0) return;
this._selectedItems = new Set();
this.lastSelectedIndex = null;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Drop selected keys that are no longer in the list, keeping the
* rest.
*
* The reason this exists rather than `clear()`: a refetch is not a
* deselection. Every naturally finished track used to invalidate
* the library cache, and `track-list` answered the new array by
* clearing the selection — so selecting forty tracks to drag into a
* playlist was impossible while music was playing (audit perf.C2).
* Keys are file paths, which survive a refetch, so the selection
* survives with them.
*
* `lastSelectedIndex` is dropped regardless: it is an index into a
* list that has just been replaced, and a shift-click against a
* stale one selects the wrong range.
*/
retain(isStillPresent: (key: string) => boolean): void {
if (this._selectedItems.size === 0) return;
const next = new Set<string>();
for (const key of this._selectedItems) {
if (isStillPresent(key)) next.add(key);
}
this.lastSelectedIndex = null;
if (next.size === this._selectedItems.size) return;
this._selectedItems = next;
// A refetch that emptied the selection also ends the mode --
// otherwise removing the last selected track from the library
// leaves the list in a state where a tap selects and the bar
// is gone.
if (next.size === 0) this._mode = false;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Select all items. */
selectAll(): void {
const count = this.host.getItemCount();
const next = new Set<string>();
for (let i = 0; i < count; i++) {
const key = this.host.getItemKey(i);
if (key !== undefined) next.add(key);
}
// Membership, not cardinality. The guard used to compare sizes
// alone, so selecting four rows and then Select All over a
// *different* four was a no-op (audit perf.p5) — reachable now
// that `retain()` above carries a selection across a refetch
// that replaced the list.
if (this.sameMembership(next)) return;
this._selectedItems = next;
this.lastSelectedIndex = count > 0 ? count - 1 : null;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Return the selected keys in the order they appear in the host's
* item list. This preserves positional ordering for queue operations.
*
* This walks the *list*, not the selection, which audit `perf.m6`
* calls out: every `dragstart`, every context-menu action and every
* favourite toggle pays it. Measured at 50 000 tracks it is **3 ms**
* — real, and a fifth of a frame, so the loop stays. What it does
* not do any more is keep going after it has found everything.
*
* It walks the list rather than the selection *deliberately*: the
* only way to order the selection directly is to store each key's
* index with it, and an index goes stale whenever the list is
* re-sorted, re-filtered or refetched, while the keys (file paths)
* survive all three — which is exactly why `retain()` drops
* `lastSelectedIndex` and keeps the keys. Trading 3 ms for a
* silently mis-ordered queue insert is not a trade.
*/
getSelectedKeysOrdered(): string[] {
const wanted = this._selectedItems.size;
if (wanted === 0) return [];
const count = this.host.getItemCount();
const result: string[] = [];
for (let i = 0; i < count; i++) {
const key = this.host.getItemKey(i);
if (key !== undefined && this._selectedItems.has(key)) {
result.push(key);
if (result.length === wanted) break;
}
}
return result;
}
/**
* Return the selected indices in ascending order.
*
* Same shape, same reasoning, same early exit as
* `getSelectedKeysOrdered()` above.
*/
getSelectedIndices(): number[] {
const wanted = this._selectedItems.size;
if (wanted === 0) return [];
const count = this.host.getItemCount();
const result: number[] = [];
for (let i = 0; i < count; i++) {
const key = this.host.getItemKey(i);
if (key !== undefined && this._selectedItems.has(key)) {
result.push(i);
if (result.length === wanted) break;
}
}
return result;
}
// =================================================================
// INTERNALS
// =================================================================
/**
* Build a Set of keys for all items between two indices (inclusive),
* handling either direction.
*/
private selectRange(from: number, to: number): Set<string> {
const start = Math.min(from, to);
const end = Math.max(from, to);
const keys = new Set<string>();
for (let i = start; i <= end; i++) {
const key = this.host.getItemKey(i);
if (key !== undefined) {
keys.add(key);
}
}
return keys;
}
}