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.
380 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|