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
334 lines
9.4 KiB
TypeScript
334 lines
9.4 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|