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.
This commit is contained in:
@@ -10,6 +10,9 @@ import {
|
||||
} from 'lit/decorators.js';
|
||||
import { SelectionController } from '@utils/selection-controller';
|
||||
import type { SelectionHost } from '@utils/selection-controller';
|
||||
import type { GestureEvent } from '@utils/touch-gestures';
|
||||
import '@components/selection-bar/selection-bar';
|
||||
import type { SelectionAction } from '@components/selection-bar/selection-bar';
|
||||
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
||||
import { PHONE_QUERY } from '@utils/breakpoints';
|
||||
import {
|
||||
@@ -77,7 +80,9 @@ import '@components/playlist-picker/playlist-picker.js';
|
||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||
import {
|
||||
ICON_PLAY,
|
||||
ICON_PLAYLIST,
|
||||
ICON_PLAY_NEXT,
|
||||
ICON_QUEUE,
|
||||
} from '@utils/icon-language';
|
||||
|
||||
@@ -1304,6 +1309,8 @@ export class TrackList
|
||||
virt.removeEventListener('click', this.onDelegatedClick);
|
||||
virt.removeEventListener('dblclick', this.onDelegatedDblClick);
|
||||
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
|
||||
virt.removeEventListener('yj-tap', this.onRowTap);
|
||||
virt.removeEventListener('yj-long-press', this.onRowLongPress);
|
||||
virt.removeEventListener('dragstart', this.onDelegatedDragStart);
|
||||
virt.removeEventListener('dragend', this.onTrackDragEnd);
|
||||
}
|
||||
@@ -1445,6 +1452,11 @@ export class TrackList
|
||||
virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
|
||||
virt.addEventListener('dragstart', this.onDelegatedDragStart);
|
||||
virt.addEventListener('dragend', this.onTrackDragEnd);
|
||||
// Delegated like the rest: the gesture layer dispatches on the
|
||||
// element the finger landed on, composed, so it arrives here
|
||||
// through the same path a real click takes (plan 019).
|
||||
virt.addEventListener('yj-tap', this.onRowTap);
|
||||
virt.addEventListener('yj-long-press', this.onRowLongPress);
|
||||
this.delegationAttached = true;
|
||||
}
|
||||
|
||||
@@ -1708,6 +1720,62 @@ export class TrackList
|
||||
if (hit) this.onTrackContextMenu(e, hit.track);
|
||||
};
|
||||
|
||||
/**
|
||||
* A finger tapped a row (plan 019, #63).
|
||||
*
|
||||
* On a desktop a click selects and a double-click plays; a finger
|
||||
* inverts that, because there is no second button and no modifier
|
||||
* key, so the primary action has to be the primary gesture.
|
||||
*
|
||||
* Claiming the gesture (`preventDefault`) is what tells the layer
|
||||
* to swallow the click behind it -- otherwise playing a track
|
||||
* would also select it, and the row would end up in both states.
|
||||
* A tap this does *not* claim falls through as an ordinary click,
|
||||
* which is what keeps the favourite icon working.
|
||||
*/
|
||||
private onRowTap = (e: GestureEvent) => {
|
||||
const hit = this.resolveTrackFromEvent(e);
|
||||
|
||||
if (!hit) return;
|
||||
|
||||
// A control inside the row owns its own tap. The same rule the
|
||||
// shortcut service has for a focused control that owns a key,
|
||||
// and without it the 44px favourite target (#56) becomes a
|
||||
// 44px play target.
|
||||
if ((e.target as HTMLElement).closest('.fav-icon')) return;
|
||||
|
||||
e.preventDefault();
|
||||
this.focusedIndex = hit.index;
|
||||
|
||||
if (this.selection.selectionMode) {
|
||||
this.selection.toggleInMode(hit.track.FilePath, hit.index);
|
||||
this.virtualizer?.requestUpdate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.playFromRow(hit.index);
|
||||
};
|
||||
|
||||
/**
|
||||
* A finger held a row still for half a second.
|
||||
*
|
||||
* Claiming this is what makes it *selection mode* rather than the
|
||||
* context menu it has been since plan 016 -- an unclaimed
|
||||
* `yj-long-press` still becomes a `contextmenu`, which is how the
|
||||
* card grids and Explore keep the behaviour they have.
|
||||
*/
|
||||
private onRowLongPress = (e: GestureEvent) => {
|
||||
const hit = this.resolveTrackFromEvent(e);
|
||||
|
||||
if (!hit) return;
|
||||
|
||||
e.preventDefault();
|
||||
this.focusedIndex = hit.index;
|
||||
this.selection.enterSelectionMode(hit.track.FilePath, hit.index);
|
||||
this.virtualizer?.requestUpdate();
|
||||
};
|
||||
|
||||
private onDelegatedDragStart = (e: DragEvent) => {
|
||||
const hit = this.resolveTrackFromEvent(e);
|
||||
|
||||
@@ -2238,6 +2306,45 @@ export class TrackList
|
||||
this.saveSortPreferences();
|
||||
};
|
||||
|
||||
/**
|
||||
* The three worth a thumb. Everything else is behind "More",
|
||||
* which opens the context menu this list already renders.
|
||||
*
|
||||
* A bar is one row on a 424px screen and the menu is nine items,
|
||||
* so this is a subset by necessity rather than a second opinion
|
||||
* about what matters -- and plan 018's promise that no action is
|
||||
* unreachable is kept by the overflow, not by this list.
|
||||
*/
|
||||
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
|
||||
{ id: 'play', label: 'Play', icon: ICON_PLAY },
|
||||
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
|
||||
{ id: 'play-next', label: 'Play next', icon: ICON_PLAY_NEXT },
|
||||
];
|
||||
|
||||
private renderSelectionBar() {
|
||||
// Only in selection mode: a mouse selection is modeless and
|
||||
// shows its actions on right-click, which is where a desktop
|
||||
// user looks for them.
|
||||
if (!this.selection.selectionMode) return nothing;
|
||||
|
||||
return html`
|
||||
<selection-bar
|
||||
.count=${this.selection.selectionCount}
|
||||
.actions=${TrackList.SELECTION_ACTIONS}
|
||||
@selection-exit=${this.onSelectionExit}
|
||||
@selection-action=${(e: CustomEvent<{ id: string }>) =>
|
||||
this.onContextMenuAction(e.detail.id)}
|
||||
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
|
||||
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
|
||||
></selection-bar>
|
||||
`;
|
||||
}
|
||||
|
||||
private onSelectionExit = () => {
|
||||
this.selection.exitSelectionMode();
|
||||
this.virtualizer?.requestUpdate();
|
||||
};
|
||||
|
||||
override render() {
|
||||
const visibleTracks = this.cachedSortedTracks;
|
||||
const cols = this.activeColumns;
|
||||
@@ -2322,6 +2429,7 @@ export class TrackList
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
${this.renderSelectionBar()}
|
||||
`}
|
||||
|
||||
<menu-surface
|
||||
|
||||
Reference in New Issue
Block a user