feat(android): the touch model reaches the other three lists
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
This commit is contained in:
@@ -26,6 +26,9 @@ import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
|
||||
|
||||
const HELD = LONG_PRESS_MS + 120;
|
||||
|
||||
/** Comfortably past `explore-link`'s own DOUBLE_CLICK_GRACE_MS of 250. */
|
||||
const EXPLORE_LINK_GRACE = 400;
|
||||
const BRIEF = Math.round(LONG_PRESS_MS / 4);
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -39,6 +42,12 @@ function track(n: number) {
|
||||
Album: 'An Album',
|
||||
Duration: 100 + n,
|
||||
ID: n,
|
||||
// Tagged, so the title's `explore-link` can actually navigate.
|
||||
// Without an MBID it asks the backend for a local album first and
|
||||
// gives up when nothing answers -- which makes "the tap did not
|
||||
// navigate" true of every build, working or not.
|
||||
ReleaseGroupMBID: 'e8f4b1d2-0000-4000-8000-00000000000' + n,
|
||||
RecordingMBID: 'a1b2c3d4-0000-4000-8000-00000000000' + n,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,11 +69,24 @@ function press(el: EventTarget, type: string, init: PointerEventInit = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A whole finger tap: down, a moment, up. */
|
||||
/**
|
||||
* A whole finger tap: down, a moment, up, and **the click a browser
|
||||
* fires afterwards**.
|
||||
*
|
||||
* That last event is not decoration. A tap the component claims has
|
||||
* its click swallowed at document capture, and the click is the only
|
||||
* thing that would otherwise select the row, follow the `explore-link`
|
||||
* in its title, or press whatever the finger landed on. A helper that
|
||||
* stops at `pointerup` asserts none of that and passes on a build with
|
||||
* the swallow deleted.
|
||||
*/
|
||||
async function tap(el: EventTarget) {
|
||||
press(el, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
press(el, 'pointerup');
|
||||
el.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }),
|
||||
);
|
||||
await wait(0);
|
||||
}
|
||||
|
||||
@@ -277,3 +299,98 @@ describe('<selection-bar>', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* What the gestures leave behind (plan 019 phase 4, #63).
|
||||
*
|
||||
* Every track, album and artist name in a row is an `explore-link`,
|
||||
* which navigates on a genuine single click — and a row's single
|
||||
* *tap* now plays. That conflict is #67's to answer properly; what
|
||||
* this plan committed to is the narrower half of it, that **tap-to-play
|
||||
* wins on touch**, and it falls out of phase 1's design rather than
|
||||
* needing a rule: a claimed tap has its click swallowed at document
|
||||
* capture, so the link's own handler never runs.
|
||||
*
|
||||
* It falls out, which is exactly why it is asserted. Nothing else in
|
||||
* the suite would fail if the swallow stopped covering the link, and
|
||||
* the symptom — a tap on a track's title navigating to its album
|
||||
* instead of playing it — is one a phone user meets constantly and a
|
||||
* mouse user never does.
|
||||
*/
|
||||
describe('a tap on a name inside a row', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||
stub('config.Config.GetShortcuts', {});
|
||||
stub('queue.Queue.SetQueue', null);
|
||||
uninstall = installTouchGestures();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstall?.();
|
||||
uninstall = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Where the row's title is rendered, which is a link. */
|
||||
function titleLink(el: HTMLElement, row: number): HTMLElement {
|
||||
const link = rows(el)[row]?.querySelector('.explore-link');
|
||||
|
||||
expect(link, 'the row renders its title as a link').toBeTruthy();
|
||||
|
||||
return link as HTMLElement;
|
||||
}
|
||||
|
||||
it('plays the row rather than navigating', async () => {
|
||||
const el = await mountList();
|
||||
const navigations: Event[] = [];
|
||||
|
||||
document.addEventListener('navigate', (e) => navigations.push(e));
|
||||
|
||||
await tap(titleLink(el, 1));
|
||||
await flush();
|
||||
// `explore-link` holds a navigation for DOUBLE_CLICK_GRACE_MS, so
|
||||
// asserting sooner passes on a build that is about to navigate.
|
||||
await wait(EXPLORE_LINK_GRACE);
|
||||
|
||||
expect(calls('queue.Queue.SetQueue').length, 'the row played').toBe(1);
|
||||
expect(navigations, 'and nothing navigated').toHaveLength(0);
|
||||
});
|
||||
|
||||
it('toggles the row while selection mode is on', async () => {
|
||||
const el = await mountList();
|
||||
const navigations: Event[] = [];
|
||||
|
||||
document.addEventListener('navigate', (e) => navigations.push(e));
|
||||
|
||||
await hold(rows(el)[0]!);
|
||||
await el.updateComplete;
|
||||
await tap(titleLink(el, 2));
|
||||
await el.updateComplete;
|
||||
await wait(EXPLORE_LINK_GRACE);
|
||||
|
||||
expect(
|
||||
(shadow(el, 'selection-bar') as unknown as { count: number }).count,
|
||||
).toBe(2);
|
||||
expect(navigations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves the mode on Escape', async () => {
|
||||
// A mode changes what a tap means, so it needs an exit that is not
|
||||
// "find the x". It is a dismissal rather than a shortcut, which is
|
||||
// why it is not a panel-scoped binding.
|
||||
const el = await mountList();
|
||||
|
||||
await hold(rows(el)[0]!);
|
||||
await el.updateComplete;
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'selection-bar')).toBeFalsy();
|
||||
expect(rows(el)[0]?.getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* The other three selecting surfaces (plan 019 phase 3, #63).
|
||||
*
|
||||
* `track-list` got the gestures in phases 1 and 2; the queue panel and
|
||||
* both playlist detail views are the rest, and phase 3 was "mostly
|
||||
* wiring" only in the sense that they already share
|
||||
* `SelectionController`. Two of them are not symmetric with the track
|
||||
* list at all, and those two asymmetries are what this file is for:
|
||||
*
|
||||
* **A tap on a queue row plays that position in the queue.** 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 everything inserted by hand along the way. It is
|
||||
* not the no-op it reads as.
|
||||
*
|
||||
* **The queue panel has no swipe, deliberately.** A right swipe means
|
||||
* *add to the queue* everywhere 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. So the
|
||||
* assertion is that the rows do not opt in — a swipe there must not
|
||||
* silently become a second meaning for the app's one horizontal
|
||||
* gesture.
|
||||
*
|
||||
* The playlist views are the symmetric half, and they are here because
|
||||
* they bind their gestures **per template** rather than through the
|
||||
* `firstUpdated` delegation the two virtualized lists use, so "the
|
||||
* handler is attached at all" is a different question in each.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import '@components/queue-panel/queue-panel';
|
||||
import '@components/playlist-details/playlist-details';
|
||||
|
||||
import { Events } from '../../src/events';
|
||||
import { calls, emit, flush, resetHarness, stub } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
|
||||
import type { QueueTrack } from '@store/queue-store';
|
||||
|
||||
const HELD = LONG_PRESS_MS + 120;
|
||||
const BRIEF = Math.round(LONG_PRESS_MS / 4);
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let uninstall: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
// The layer is one document listener set, so a suite that leaves it
|
||||
// installed makes the next file's gestures fire twice.
|
||||
uninstall?.();
|
||||
uninstall = null;
|
||||
});
|
||||
|
||||
function queueTrack(n: number): QueueTrack {
|
||||
return {
|
||||
id: n,
|
||||
audioFileId: n,
|
||||
filePath: `/music/${n}.mp3`,
|
||||
position: n,
|
||||
title: `Track ${n}`,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
coverArtPath: '',
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
}
|
||||
|
||||
const QUEUE = [1, 2, 3, 4].map(queueTrack);
|
||||
|
||||
function playlistTrack(n: number) {
|
||||
return {
|
||||
ID: n,
|
||||
FilePath: `/music/${n}.mp3`,
|
||||
Title: `Track ${n}`,
|
||||
Artist: 'Artist',
|
||||
Album: 'Album',
|
||||
Duration: 100 + n,
|
||||
Position: n,
|
||||
Phantom: false,
|
||||
};
|
||||
}
|
||||
|
||||
const PLAYLIST_TRACKS = [1, 2, 3, 4].map(playlistTrack);
|
||||
|
||||
function press(el: EventTarget, type: string, init: PointerEventInit = {}) {
|
||||
el.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
...init,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function tap(el: EventTarget) {
|
||||
press(el, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
press(el, 'pointerup');
|
||||
await wait(0);
|
||||
}
|
||||
|
||||
async function hold(el: EventTarget) {
|
||||
press(el, 'pointerdown');
|
||||
await wait(HELD);
|
||||
press(el, 'pointerup');
|
||||
await wait(0);
|
||||
}
|
||||
|
||||
/** Drag a row sideways by `dx` and lift, as one finger. */
|
||||
async function swipe(el: EventTarget, dx: number) {
|
||||
const at = (x: number) =>
|
||||
new Touch({
|
||||
identifier: 1,
|
||||
target: el as Element,
|
||||
clientX: x,
|
||||
clientY: 100,
|
||||
});
|
||||
const send = (type: string, points: Touch[]) =>
|
||||
el.dispatchEvent(
|
||||
new TouchEvent(type, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
touches: points,
|
||||
changedTouches: points.length > 0 ? points : [at(0)],
|
||||
}),
|
||||
);
|
||||
|
||||
send('touchstart', [at(0)]);
|
||||
|
||||
for (const step of [0.25, 0.5, 0.75, 1]) {
|
||||
send('touchmove', [at(dx * step)]);
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
send('touchend', []);
|
||||
await wait(0);
|
||||
}
|
||||
|
||||
/** Past any commit threshold the row could compute. */
|
||||
const FAR = 400;
|
||||
|
||||
describe('a finger on a queue row', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('config.Config.GetShortcuts', {});
|
||||
stub('queue.Queue.PlayIndex', null);
|
||||
stub('queue.Queue.SetQueue', null);
|
||||
stub('queue.Queue.AddTracks', null);
|
||||
uninstall = installTouchGestures();
|
||||
});
|
||||
|
||||
async function panel() {
|
||||
const el = await fixture('queue-panel', { open: true });
|
||||
|
||||
emit(Events.QueueChanged, {
|
||||
tracks: QUEUE,
|
||||
currentIndex: 0,
|
||||
shuffleMode: false,
|
||||
repeatMode: 'off',
|
||||
sourcePlaylistId: 0,
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => {
|
||||
requestAnimationFrame(() => r(null));
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
const rows = (el: HTMLElement) => shadowAll<HTMLElement>(el, '.track-item');
|
||||
|
||||
it('plays that position rather than rebuilding the queue', async () => {
|
||||
const el = await panel();
|
||||
|
||||
await tap(rows(el)[2]!);
|
||||
await flush();
|
||||
|
||||
expect(calls('queue.Queue.PlayIndex')[0]?.args[0]).toBe(2);
|
||||
|
||||
// The asymmetry with `track-list`: setting the queue here would
|
||||
// discard its source, its shuffle order and anything inserted by
|
||||
// hand, which is not the no-op it reads as.
|
||||
expect(calls('queue.Queue.SetQueue').length).toBe(0);
|
||||
});
|
||||
|
||||
it('enters selection mode on a hold, with that row selected', async () => {
|
||||
const el = await panel();
|
||||
|
||||
await hold(rows(el)[1]!);
|
||||
await el.updateComplete;
|
||||
|
||||
const bar = shadow(el, 'selection-bar');
|
||||
|
||||
expect(bar, 'the action bar appears').toBeTruthy();
|
||||
expect((bar as unknown as { count: number }).count).toBe(1);
|
||||
expect(calls('queue.Queue.PlayIndex').length, 'and nothing played').toBe(0);
|
||||
});
|
||||
|
||||
it('toggles rows while the mode is on, instead of playing them', async () => {
|
||||
const el = await panel();
|
||||
|
||||
await hold(rows(el)[0]!);
|
||||
await el.updateComplete;
|
||||
await tap(rows(el)[2]!);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(calls('queue.Queue.PlayIndex').length).toBe(0);
|
||||
expect(
|
||||
(shadow(el, 'selection-bar') as unknown as { count: number }).count,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('has no swipe, which is a decision and not an omission', async () => {
|
||||
const el = await panel();
|
||||
|
||||
expect(
|
||||
rows(el)[1]?.hasAttribute('data-swipe'),
|
||||
'the row does not opt into the shared rule',
|
||||
).toBe(false);
|
||||
|
||||
await swipe(rows(el)[1]!, FAR);
|
||||
await flush();
|
||||
|
||||
// A right swipe means "add to the queue" everywhere it exists.
|
||||
// The only thing it could mean on a queue row is "remove", which
|
||||
// is the same gesture with the opposite effect one screen away.
|
||||
expect(calls('queue.Queue.AddTracks').length).toBe(0);
|
||||
expect(calls('queue.Queue.RemoveTracks').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a finger on a playlist row', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('config.Config.GetShortcuts', {});
|
||||
stub('playlist.Service.GetPlaylist', {
|
||||
ID: 1,
|
||||
Name: 'A Playlist',
|
||||
TrackCount: PLAYLIST_TRACKS.length,
|
||||
});
|
||||
stub('playlist.Service.GetPlaylistTracks', PLAYLIST_TRACKS);
|
||||
stub('queue.Queue.SetQueue', null);
|
||||
stub('queue.Queue.AddTracks', null);
|
||||
uninstall = installTouchGestures();
|
||||
});
|
||||
|
||||
async function details() {
|
||||
const el = await fixture('playlist-details', {
|
||||
playlistId: 1,
|
||||
playlistName: 'A Playlist',
|
||||
});
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await wait(60);
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
const rows = (el: HTMLElement) => shadowAll<HTMLElement>(el, '.track-item');
|
||||
|
||||
it('plays the playlist from the row it taps', async () => {
|
||||
const el = await details();
|
||||
|
||||
expect(rows(el).length, 'the list rendered rows').toBeGreaterThan(2);
|
||||
|
||||
await tap(rows(el)[2]!);
|
||||
await flush();
|
||||
|
||||
const queued = calls('queue.Queue.SetQueue');
|
||||
|
||||
// The app's rule: activating one row plays the list that row is
|
||||
// in, from that row -- not a queue of one that stops when the song
|
||||
// ends.
|
||||
expect(queued.length).toBe(1);
|
||||
expect(queued[0]?.args[1]).toBe(2);
|
||||
expect((queued[0]?.args[0] as string[]).length).toBe(
|
||||
PLAYLIST_TRACKS.length,
|
||||
);
|
||||
});
|
||||
|
||||
it('enters selection mode on a hold', async () => {
|
||||
const el = await details();
|
||||
|
||||
await hold(rows(el)[1]!);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(
|
||||
(shadow(el, 'selection-bar') as unknown as { count: number } | null)
|
||||
?.count,
|
||||
).toBe(1);
|
||||
expect(calls('queue.Queue.SetQueue').length, 'nothing played').toBe(0);
|
||||
});
|
||||
|
||||
it('queues the row a swipe crosses', async () => {
|
||||
const el = await details();
|
||||
|
||||
await swipe(rows(el)[1]!, FAR);
|
||||
await flush();
|
||||
|
||||
expect(calls('queue.Queue.AddTracks')[0]?.args[0]).toEqual([
|
||||
'/music/2.mp3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('opts its rows into the shared touch-action rule', async () => {
|
||||
// Half of what makes the gesture reach us on the device, and
|
||||
// invisible in this browser either way -- the other half is the
|
||||
// non-passive preventDefault in `utils/touch-gestures.ts`.
|
||||
const el = await details();
|
||||
|
||||
expect(rows(el)[0]?.hasAttribute('data-swipe')).toBe(true);
|
||||
|
||||
const css = (
|
||||
customElements.get('playlist-details') as unknown as {
|
||||
styles: { cssText: string }[];
|
||||
}
|
||||
).styles
|
||||
.map((s) => s.cssText)
|
||||
.join('\n');
|
||||
|
||||
expect(css).toContain('touch-action: pan-y');
|
||||
});
|
||||
});
|
||||
@@ -266,12 +266,20 @@ describe('a finger swiped right across a track row', () => {
|
||||
expect(rows(el)[0]?.getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it('declares pan-y on the row, which is half of what makes it work', () => {
|
||||
// The other half is the module's non-passive `preventDefault`.
|
||||
// Neither works alone on Chrome 113 and both are irrelevant here,
|
||||
// so this reads the stylesheet rather than the rendering — the
|
||||
// regression is someone tidying the declaration away, and nothing
|
||||
// in this browser looks different when they do.
|
||||
it('declares pan-y on the row, which is half of what makes it work', async () => {
|
||||
// The other half is the gesture module's non-passive
|
||||
// `preventDefault`. Neither works alone on Chrome 113 and both are
|
||||
// irrelevant here, so this reads the stylesheet and the attribute
|
||||
// rather than the rendering — the regression is someone tidying
|
||||
// one of them away, and nothing in this browser looks different
|
||||
// when they do.
|
||||
const el = await mountList();
|
||||
|
||||
expect(
|
||||
rows(el)[0]?.hasAttribute('data-swipe'),
|
||||
'the row opts into the shared rule',
|
||||
).toBe(true);
|
||||
|
||||
const sheets = (
|
||||
customElements.get('track-list') as unknown as {
|
||||
styles: { cssText: string }[];
|
||||
@@ -280,9 +288,9 @@ describe('a finger swiped right across a track row', () => {
|
||||
const css = sheets.map((s) => s.cssText).join('\n');
|
||||
const rule = css
|
||||
.split('}')
|
||||
.find((block) => /\.track-row\s*\{/.test(block));
|
||||
.find((block) => /\[data-swipe\]\s*\{/.test(block));
|
||||
|
||||
expect(rule, 'the row rule is still there to read').toBeTruthy();
|
||||
expect(rule, 'the shared rule is in this component').toBeTruthy();
|
||||
expect(rule).toContain('touch-action: pan-y');
|
||||
expect(css, 'never none: it takes the scrolling too').not.toContain(
|
||||
'touch-action: none',
|
||||
|
||||
Reference in New Issue
Block a user