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
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
import { css, html, nothing } from 'lit';
|
||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||
import { classMap } from 'lit/directives/class-map.js';
|
||
|
||
import { queueStore } from '@store/queue-store';
|
||
import { ICON_QUEUE } from '@utils/icon-language';
|
||
import type { SwipeEvent } from '@utils/touch-gestures';
|
||
|
||
/**
|
||
* Swipe a row right to add it to the queue (plan 019, #63).
|
||
*
|
||
* This is the *affordance* and the arithmetic, written once, because
|
||
* three lists want it: `track-list` and both playlist detail views.
|
||
* It was `track-list`'s own for one phase and is here rather than
|
||
* copied twice, on the rule the rest of this app is built on — three
|
||
* copies of "how far is far enough" is three chances for them to
|
||
* disagree, which is what `utils/library-status.ts` and
|
||
* `utils/ownership.ts` each exist to have stopped happening.
|
||
*
|
||
* The host keeps three things: what a row *is*, what a swipe on it
|
||
* would queue, and what to call it afterwards. Everything else —
|
||
* the threshold, the reveal, the settle, the announcement, the
|
||
* repaint — is here.
|
||
*
|
||
* **The queue panel deliberately does not use it.** 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 sensibly mean there is
|
||
* *remove*, which is the same gesture with the opposite effect one
|
||
* screen away. Removing a queue row is on its own row (the ×), on its
|
||
* bottom sheet since #60, and on the selection bar #63 gave it.
|
||
*
|
||
* Five things about it are load-bearing.
|
||
*
|
||
* **Both halves of the device fix are here or next door.**
|
||
* `swipeRevealStyles` carries `touch-action: pan-y` on `[data-swipe]`,
|
||
* and `utils/touch-gestures.ts` carries the non-passive
|
||
* `preventDefault`. Chrome 113's WebView cancels the pointer stream
|
||
* ~16px into any drag whatever `touch-action` says, and with the
|
||
* `preventDefault` alone but `touch-action` at `auto` the gesture dies
|
||
* after one move. Neither works without the other and **both are
|
||
* correct in Chromium either way**, which is why the component tier
|
||
* asserts the stylesheet rather than the rendering.
|
||
*
|
||
* **The row does not move; its cells do.** A row here is
|
||
* `contain: strict` with `overflow: hidden`, so translating the row
|
||
* and counter-translating a pane inside it puts that pane at a
|
||
* negative offset inside a clipping box, where it is simply not
|
||
* painted. Sliding the children instead leaves the pane where it was
|
||
* drawn, clips the cells off the right edge, and needs no wrapper
|
||
* element in a row that is already a grid.
|
||
*
|
||
* **The travel is written to the row's own style, never rendered.**
|
||
* One render when the gesture starts, one when it crosses the
|
||
* threshold, one when it ends — a virtualizer re-rendering every
|
||
* visible row per frame of one finger's travel is exactly what audit
|
||
* `perf.m1` is about.
|
||
*
|
||
* **The threshold is a fraction of the row**, with a floor. The row is
|
||
* 424x52 on the reference device, so a threshold in bare pixels is a
|
||
* fraction of a row height on one screen and a third of the width on
|
||
* the next.
|
||
*
|
||
* **It is not only a colour** (WCAG 1.4.1, the rule the playing-row
|
||
* marker exists for). The pane carries the queue icon and words, the
|
||
* words change at the threshold, and the outcome goes to a live
|
||
* region — one glyph throughout, because a tick is `ICON_IN_LIBRARY`
|
||
* and means *you own this*.
|
||
*/
|
||
|
||
/** How far along the row a swipe has to reach to mean it. */
|
||
export const SWIPE_COMMIT_FRACTION = 0.3;
|
||
|
||
/** … and a floor, for a narrow list embedded in a detail page. */
|
||
export const SWIPE_COMMIT_MIN_PX = 72;
|
||
|
||
/** How long the reveal holds its confirmation before snapping back. */
|
||
const CONFIRM_MS = 550;
|
||
|
||
/** The snap itself. `swipeRevealStyles` states the same number. */
|
||
const SETTLE_MS = 180;
|
||
|
||
/** What a swipe on one row would do, as the host understands it. */
|
||
export interface SwipeTarget {
|
||
/** Which row draws the reveal. */
|
||
index: number;
|
||
|
||
/** The file paths a commit queues, in the order they are shown. */
|
||
filePaths: string[];
|
||
|
||
/** What to call a single track when saying it was added. */
|
||
label: string;
|
||
}
|
||
|
||
export interface SwipeToQueueOptions {
|
||
/**
|
||
* The row the gesture is on, or null for anything that is not a
|
||
* swipeable row — a header, a gap, a track with no file.
|
||
*/
|
||
resolve(e: SwipeEvent): SwipeTarget | null;
|
||
|
||
/**
|
||
* Repaint the rows. A `<lit-virtualizer>` renders through the
|
||
* `virtualize` directive and reacts to its *own* properties, so a
|
||
* host update alone leaves the rows exactly as they were.
|
||
*/
|
||
repaint(): void;
|
||
}
|
||
|
||
export class SwipeToQueue implements ReactiveController {
|
||
private host: ReactiveControllerHost;
|
||
private opts: SwipeToQueueOptions;
|
||
|
||
/** Which row is being swiped, and therefore draws a reveal. */
|
||
private index: number | null = null;
|
||
|
||
/** Past the commit threshold: the reveal says so, in words. */
|
||
private armed = false;
|
||
|
||
/** Committed, and holding its confirmation. */
|
||
private done = false;
|
||
|
||
private row: HTMLElement | null = null;
|
||
private keys: string[] = [];
|
||
private commitPx = 0;
|
||
private settleTimer = 0;
|
||
|
||
/** What the gesture did, for anyone not watching the row. */
|
||
announcement = '';
|
||
|
||
constructor(host: ReactiveControllerHost, opts: SwipeToQueueOptions) {
|
||
this.host = host;
|
||
this.opts = opts;
|
||
host.addController(this);
|
||
}
|
||
|
||
hostConnected(): void {
|
||
// No-op; state is component-local.
|
||
}
|
||
|
||
hostDisconnected(): void {
|
||
window.clearTimeout(this.settleTimer);
|
||
this.forget();
|
||
}
|
||
|
||
/** Whether this row is the one under the finger. */
|
||
isSwiping(index: number): boolean {
|
||
return this.index === index;
|
||
}
|
||
|
||
onSwipeStart = (e: SwipeEvent): void => {
|
||
// Rightward only. Nothing is bound to a leftward swipe, and
|
||
// claiming one would take a gesture away to do nothing with it.
|
||
if (e.detail.dx <= 0) return;
|
||
|
||
const target = this.opts.resolve(e);
|
||
|
||
if (!target || target.filePaths.length === 0) return;
|
||
|
||
const row = (e.target as HTMLElement).closest(
|
||
'[data-swipe]',
|
||
) as HTMLElement | null;
|
||
|
||
if (!row) return;
|
||
|
||
e.preventDefault();
|
||
|
||
this.row = row;
|
||
this.keys = target.filePaths;
|
||
this.trackLabel = target.label;
|
||
this.commitPx = Math.max(
|
||
SWIPE_COMMIT_MIN_PX,
|
||
row.getBoundingClientRect().width * SWIPE_COMMIT_FRACTION,
|
||
);
|
||
this.armed = false;
|
||
this.done = false;
|
||
this.index = target.index;
|
||
this.host.requestUpdate();
|
||
this.opts.repaint();
|
||
this.offset(0);
|
||
};
|
||
|
||
onSwipeMove = (e: SwipeEvent): void => {
|
||
if (this.index === null) return;
|
||
|
||
const dx = Math.min(Math.max(e.detail.dx, 0), this.commitPx * 2);
|
||
const armed = dx >= this.commitPx;
|
||
|
||
if (armed !== this.armed) {
|
||
this.armed = armed;
|
||
this.host.requestUpdate();
|
||
this.opts.repaint();
|
||
}
|
||
|
||
this.offset(dx);
|
||
};
|
||
|
||
onSwipeEnd = (e: SwipeEvent): void => {
|
||
if (this.index === null) return;
|
||
|
||
if (e.detail.canceled || e.detail.dx < this.commitPx) {
|
||
this.settle(0);
|
||
|
||
return;
|
||
}
|
||
|
||
queueStore.addTracksToQueue(this.keys);
|
||
|
||
const count = this.keys.length;
|
||
|
||
// The reveal is the only thing on screen that says this
|
||
// happened -- the queue panel may well be closed -- so it holds
|
||
// its confirmation for a moment rather than vanishing the
|
||
// instant the finger lifts.
|
||
this.done = true;
|
||
this.announcement =
|
||
count === 1
|
||
? `Added ${this.label()} to the queue.`
|
||
: `Added ${count} tracks to the queue.`;
|
||
this.host.requestUpdate();
|
||
this.opts.repaint();
|
||
this.settle(CONFIRM_MS);
|
||
};
|
||
|
||
/** What is revealed behind the row, in three states. */
|
||
renderReveal(index: number) {
|
||
if (this.index !== index) return nothing;
|
||
|
||
const count = this.keys.length;
|
||
const what = count === 1 ? 'to queue' : `${count} tracks to queue`;
|
||
const words = this.done
|
||
? 'Added'
|
||
: this.armed
|
||
? 'Release to add'
|
||
: `Add ${what}`;
|
||
|
||
return html`
|
||
<div
|
||
class=${classMap({ 'swipe-reveal': true, armed: this.armed })}
|
||
aria-hidden="true"
|
||
data-testid="swipe-reveal"
|
||
>
|
||
<wa-icon name=${ICON_QUEUE}></wa-icon>
|
||
<span>${words}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* What to call a single track, taken when the gesture starts.
|
||
*
|
||
* Held rather than looked up at the end, because a swipe outlives
|
||
* a refetch: the store replaces its array when a play count
|
||
* changes, which is once a song.
|
||
*/
|
||
private trackLabel = '';
|
||
|
||
private label(): string {
|
||
return this.trackLabel === '' ? 'the track' : this.trackLabel;
|
||
}
|
||
|
||
/** Write the travel to the row itself, with no render. */
|
||
private offset(dx: number): void {
|
||
this.row?.style.setProperty('--yj-swipe-dx', `${dx}px`);
|
||
}
|
||
|
||
/**
|
||
* Put the row back, after `delay`, and forget the swipe.
|
||
*
|
||
* The row element is held rather than looked up again: a
|
||
* virtualizer recycles its rows, and by the time this runs the
|
||
* element may be drawing a different track. Clearing the property
|
||
* off whatever it holds now is right either way, since `index` is
|
||
* what decides who draws the reveal.
|
||
*/
|
||
private settle(delay: number): void {
|
||
const row = this.row;
|
||
|
||
window.clearTimeout(this.settleTimer);
|
||
|
||
this.settleTimer = window.setTimeout(() => {
|
||
row?.classList.add('settling');
|
||
this.offset(0);
|
||
|
||
this.settleTimer = window.setTimeout(() => {
|
||
row?.classList.remove('settling');
|
||
row?.style.removeProperty('--yj-swipe-dx');
|
||
this.forget();
|
||
this.host.requestUpdate();
|
||
this.opts.repaint();
|
||
}, SETTLE_MS);
|
||
}, delay);
|
||
}
|
||
|
||
private forget(): void {
|
||
this.row = null;
|
||
this.index = null;
|
||
this.armed = false;
|
||
this.done = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The reveal, and the `touch-action` half of what makes the gesture
|
||
* reach us on the device.
|
||
*
|
||
* Keyed on `[data-swipe]` rather than on a class name, so one
|
||
* stylesheet serves three lists whose rows are called three different
|
||
* things.
|
||
*/
|
||
export const swipeRevealStyles = css`
|
||
/* Half of what makes the gesture reach us on Chrome 113's WebView:
|
||
auto lets it commit to a horizontal pan on the first move past
|
||
slop, and the pointer stream is cancelled before any threshold
|
||
can be crossed. The other half is the non-passive preventDefault
|
||
in utils/touch-gestures.ts, and neither works alone -- both were
|
||
measured three ways on the phone. Never none: that takes the
|
||
list's own vertical scrolling with it. */
|
||
[data-swipe] {
|
||
touch-action: pan-y;
|
||
}
|
||
|
||
.swipe-reveal {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: var(--yj-swipe-dx, 0px);
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4em;
|
||
padding-left: 8px;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
font-size: var(--yj-text-xs);
|
||
background-color: var(--yj-bg-elevated, #343a40);
|
||
color: var(--yj-text-secondary, #b3b3b3);
|
||
}
|
||
|
||
.swipe-reveal.armed {
|
||
background-color: var(--yj-success, #2f9e44);
|
||
color: var(--yj-success-fg, #fff);
|
||
}
|
||
|
||
/* The children move, not the row -- see the header. */
|
||
[data-swipe].swiping > :not(.swipe-reveal) {
|
||
transform: translateX(var(--yj-swipe-dx, 0px));
|
||
}
|
||
|
||
[data-swipe].settling > * {
|
||
transition:
|
||
transform 160ms ease-out,
|
||
width 160ms ease-out;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
[data-swipe].settling > * {
|
||
transition: none;
|
||
}
|
||
}
|
||
`;
|