feat(android): the touch model reaches the other three lists
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m33s
CI / e2e (pull_request) Successful in 9m20s

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:
2026-08-22 01:41:21 -04:00
parent 4e667759c4
commit 29feb4b94b
11 changed files with 1402 additions and 291 deletions
@@ -38,6 +38,10 @@ import {
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
@@ -71,11 +75,14 @@ import {
exploreLinkStyles,
} from '@utils/explore-link';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
import {
ICON_PLAY,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_REMOVE,
} from '@utils/icon-language';
/** One playlist row: the track and its position in the *playlist*,
@@ -422,6 +429,127 @@ export class PlaylistDetails
queueStore.setQueue(filePaths, trackIndex, false, { type: 'playlist', id: this.playlistId, label: this.playlistName });
}
// =================================================================
// A finger on a playlist row (plan 019 phase 3, #63)
// =================================================================
/** The row an announced gesture is on, with its track. */
private rowFromGesture(
e: Event,
): { index: number; track: playlist.Track } | null {
const row = (e.target as HTMLElement).closest(
'.track-item',
) as HTMLElement | null;
if (!row) return null;
const index = Number(row.dataset.index);
const track = this.tracks[index];
if (Number.isNaN(index) || !track) return null;
return { index, track };
}
/**
* A tap plays the playlist from that row.
*
* The same thing a double-click does, which is the rule the whole
* app follows: activating one row plays the list the row is in,
* from that row, rather than a queue of one that stops when the
* song ends.
*/
private onRowTap = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
if (this.selection.selectionMode) {
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.toggleInMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
return;
}
// A missing file has nothing to play, so the tap is left
// unclaimed and falls through to the click that selects it --
// which is what a mouse does here and the only useful thing a
// phantom row can answer.
if (hit.track.Phantom) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.handleTrackDblClick(hit.index);
};
private onRowLongPress = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
};
/**
* Swipe a row right to queue it.
*
* `track-list`'s rule, one list over: one row is a position and
* several rows are an explicit choice, and a swipe never changes
* the selection it reads.
*/
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.rowFromGesture(e);
// A phantom has no file to queue, so there is nothing for
// the reveal to promise.
if (!hit || hit.track.Phantom) return null;
const selected = this.selection.getSelectedIndices();
const many =
selected.length > 1 && selected.includes(hit.index);
const filePaths = many
? this.getSelectedFilePaths()
: [hit.track.FilePath];
return { index: hit.index, filePaths, label: hit.track.Title };
},
repaint: () => this.virtualizer?.requestUpdate(),
});
/** The three worth a thumb; the sheet behind "More" is the rest. */
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: 'remove', label: 'Remove', icon: ICON_REMOVE, danger: true },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${PlaylistDetails.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();
};
private handleTrackContextMenu(
e: MouseEvent,
trackIndex: number,
@@ -955,9 +1083,11 @@ export class PlaylistDetails
static override styles = [
designTokens,
srOnly,
backButton,
contextMenuStyles,
exploreLinkStyles,
swipeRevealStyles,
css`
:host {
display: flex;
@@ -1136,6 +1266,9 @@ export class PlaylistDetails
.track-item {
width: 100%;
box-sizing: border-box;
/* The swipe reveal is absolute inside the row. */
position: relative;
overflow: hidden;
}
.track-header {
@@ -1433,6 +1566,9 @@ export class PlaylistDetails
<div class="header-cell col-album">Album</div>
<div class="header-cell col-duration">Duration</div>
</div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
<lit-virtualizer
class="track-scroller"
role="listbox"
@@ -1442,7 +1578,13 @@ export class PlaylistDetails
.renderItem=${this.renderRow}
.keyFunction=${this.rowKey}
.layout=${this.flowLayout}
@yj-tap=${this.onRowTap}
@yj-long-press=${this.onRowLongPress}
@yj-swipe-start=${this.swipe.onSwipeStart}
@yj-swipe-move=${this.swipe.onSwipeMove}
@yj-swipe-end=${this.swipe.onSwipeEnd}
></lit-virtualizer>
${this.renderSelectionBar()}
`;
}
@@ -1464,6 +1606,7 @@ export class PlaylistDetails
active ? 'active' : '',
selected ? 'selected' : '',
isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
]
.filter(Boolean)
.join(' ');
@@ -1474,6 +1617,7 @@ export class PlaylistDetails
role="option"
aria-selected=${selected}
data-index=${trackIndex}
data-swipe
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)}
@@ -1520,6 +1664,7 @@ export class PlaylistDetails
? nothing
: this.onTrackDragEnd}
>
${this.swipe.renderReveal(trackIndex)}
${isPhantom
? html`<div
class="phantom-row"
@@ -64,9 +64,14 @@ import {
} from '@utils/explore-link';
import {
ICON_NEW,
ICON_PLAY,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_REMOVE,
} from '@utils/icon-language';
import type { GestureEvent } from '@utils/touch-gestures';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
/** Above this many tracks, clearing the queue asks first. */
const CLEAR_CONFIRM_THRESHOLD = 20;
@@ -844,6 +849,8 @@ export class QueuePanel
virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
virtEl.addEventListener('dragend', this.onTrackDragEnd);
virtEl.addEventListener('keydown', this.onDelegatedKeydown);
virtEl.addEventListener('yj-tap', this.onRowTap);
virtEl.addEventListener('yj-long-press', this.onRowLongPress);
this.delegationAttached = true;
}
@@ -962,6 +969,8 @@ export class QueuePanel
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
virtEl.removeEventListener('dragend', this.onTrackDragEnd);
virtEl.removeEventListener('keydown', this.onDelegatedKeydown);
virtEl.removeEventListener('yj-tap', this.onRowTap);
virtEl.removeEventListener('yj-long-press', this.onRowLongPress);
}
this.delegationAttached = false;
}
@@ -1247,6 +1256,92 @@ export class QueuePanel
this.queue.playAtIndex(index);
}
// =================================================================
// A finger on a queue row (plan 019 phase 3, #63)
// =================================================================
/**
* A tap plays this position in the queue.
*
* `track-list`'s tap sets the queue to the list it was made in;
* copying that here would rebuild the queue from the queue, which
* is not the no-op it looks like -- it would discard the queue's
* source, its shuffle order and everything a user had inserted by
* hand. `playAtIndex` is what a double-click already does, and it
* is what a tap means.
*/
private onRowTap = (e: GestureEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx === null) 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. The remove button is the one here.
if ((e.target as HTMLElement).closest('.remove-button')) return;
e.preventDefault();
// The roving tab stop follows the finger, or Tab returns to
// wherever the arrows last were rather than to the row that was
// just touched.
this.focusedIndex = idx;
if (this.selection.selectionMode) {
this.selection.toggleInMode(String(idx), idx);
this.virtualizer?.requestUpdate();
return;
}
this.selection.clear();
this.queue.playAtIndex(idx);
};
private onRowLongPress = (e: GestureEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx === null) return;
e.preventDefault();
this.focusedIndex = idx;
this.selection.enterSelectionMode(String(idx), idx);
this.virtualizer?.requestUpdate();
};
/**
* The two worth a thumb, and "More" for the rest.
*
* Remove is here rather than left to the overflow because it is
* what a selection in a *queue* is most often made for, and it is
* the action the row's own × offers one row at a time.
*/
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'remove', label: 'Remove', icon: ICON_REMOVE, danger: true },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${QueuePanel.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();
};
private handleTrackContextMenu(
e: MouseEvent,
index: number,
@@ -2157,6 +2252,7 @@ export class QueuePanel
></lit-virtualizer>
`}
</div>
${this.renderSelectionBar()}
</div>
<menu-surface
@@ -39,6 +39,11 @@ import { ICON_MORE_ACTIONS } from '@utils/icon-language';
* region: the number changes under the user's finger as they tap rows,
* and nothing else on screen announces it.
*
* **Escape leaves the mode, from here rather than from each host.**
* This element exists only while the mode does, so it is the one place
* a dismissal can be attached and detached with the thing it
* dismisses. It is the same exception the overlaid queue's Escape is.
*
* **It renders nothing at zero.** The mode ends when the last row is
* deselected — `SelectionController.toggleInMode` is where that is
* decided — so a bar with a count of none is a state this should never
@@ -120,6 +125,43 @@ export class SelectionBar extends LitElement {
);
}
/**
* Escape leaves the mode.
*
* A mode changes what a tap means, so it has to have an exit that
* is not "find the ×" -- and this is the documented exception to
* the app's one-keyboard-authority rule, on exactly the grounds
* the overlaid queue's Escape is: **it is a dismissal, not a
* shortcut**, so it is not a panel-scoped binding and it is
* attached only while there is something to dismiss. Putting it
* here rather than in each host is what gives all four surfaces
* the same answer, since this element exists only while the mode
* does.
*
* The platform's own back gesture is the other half of that and is
* deliberately *not* here: the shell owns the history stack
* (#6/#55), and a component reaching for `history` itself is how
* two stacks come to disagree about what one press means -- the
* fault that deleted `navStack`. See #200.
*/
private onKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || this.count <= 0) return;
e.preventDefault();
e.stopPropagation();
this.emit('selection-exit');
};
override connectedCallback() {
super.connectedCallback();
document.addEventListener('keydown', this.onKeydown, true);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keydown', this.onKeydown, true);
}
override render() {
if (this.count <= 0) return nothing;
@@ -28,6 +28,10 @@ import {
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { FavoritesController } from '@store/controllers/favorites-controller';
import {
setDragPayload,
@@ -63,8 +67,11 @@ import {
import '@components/smart-playlist-editor/smart-playlist-editor.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { srOnly } from '../../styles/sr-only.css';
import { list } from '@utils/binding';
import {
ICON_PLAY,
ICON_PLAY_NEXT,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_SMART_PLAYLIST,
@@ -243,9 +250,11 @@ export class SmartPlaylistDetails
static override styles = [
designTokens,
srOnly,
backButton,
contextMenuStyles,
exploreLinkStyles,
swipeRevealStyles,
css`
:host {
display: flex;
@@ -444,6 +453,9 @@ export class SmartPlaylistDetails
.track-item {
width: 100%;
box-sizing: border-box;
/* The swipe reveal is absolute inside the row. */
position: relative;
overflow: hidden;
}
.track-header {
@@ -920,6 +932,110 @@ export class SmartPlaylistDetails
// Context menu actions
// =================================================================
// =================================================================
// A finger on a smart playlist row (plan 019 phase 3, #63)
// =================================================================
/** The row an announced gesture is on, with its track. */
private rowFromGesture(
e: Event,
): { index: number; track: playlist.Track } | null {
const row = (e.target as HTMLElement).closest(
'.track-item',
) as HTMLElement | null;
if (!row) return null;
const index = Number(row.dataset.index);
const track = this.tracks[index];
if (Number.isNaN(index) || !track) return null;
return { index, track };
}
/** A tap plays the playlist from that row -- `playlist-details`'
* rule, and the app's: activating a row plays the list it is in. */
private onRowTap = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
if (this.selection.selectionMode) {
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.toggleInMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
return;
}
// A missing file has nothing to play, so the tap falls through
// to the click that selects it.
if (hit.track.Phantom) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.handleTrackDblClick(hit.index);
};
private onRowLongPress = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
};
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.rowFromGesture(e);
if (!hit || hit.track.Phantom) return null;
const selected = this.selection.getSelectedIndices();
const many =
selected.length > 1 && selected.includes(hit.index);
const filePaths = many
? this.getSelectedFilePaths()
: [hit.track.FilePath];
return { index: hit.index, filePaths, label: hit.track.Title };
},
repaint: () => this.virtualizer?.requestUpdate(),
});
/** The three worth a thumb; the sheet behind "More" is the rest. */
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() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${SmartPlaylistDetails.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();
};
private onContextMenuAction(action: string) {
const filePaths = this.getSelectedFilePaths();
@@ -1334,6 +1450,9 @@ export class SmartPlaylistDetails
<div class="header-cell col-album">Album</div>
<div class="header-cell col-duration">Duration</div>
</div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
<lit-virtualizer
role="listbox"
aria-label="Smart playlist tracks"
@@ -1342,7 +1461,13 @@ export class SmartPlaylistDetails
.renderItem=${this.renderRow}
.keyFunction=${this.rowKey}
.layout=${this.flowLayout}
@yj-tap=${this.onRowTap}
@yj-long-press=${this.onRowLongPress}
@yj-swipe-start=${this.swipe.onSwipeStart}
@yj-swipe-move=${this.swipe.onSwipeMove}
@yj-swipe-end=${this.swipe.onSwipeEnd}
></lit-virtualizer>
${this.renderSelectionBar()}
`;
}
@@ -1364,6 +1489,7 @@ export class SmartPlaylistDetails
active ? 'active' : '',
selected ? 'selected' : '',
isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
]
.filter(Boolean)
.join(' ');
@@ -1374,6 +1500,7 @@ export class SmartPlaylistDetails
role="option"
aria-selected=${selected}
data-index=${trackIndex}
data-swipe
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)}
@@ -1408,6 +1535,7 @@ export class SmartPlaylistDetails
? nothing
: this.onTrackDragEnd}
>
${this.swipe.renderReveal(trackIndex)}
${isPhantom
? html`<div class="phantom-row">
<wa-icon
+33 -269
View File
@@ -10,7 +10,8 @@ import {
} from 'lit/decorators.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import type { GestureEvent, SwipeEvent } from '@utils/touch-gestures';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
@@ -282,36 +283,28 @@ export class TrackList
* path into it at all (H-5). */
@state() private focusedIndex = 0;
// --- swipe right to queue (plan 019 phase 2, #63) ----------------
/**
* Swipe a row right to queue it (plan 019 phase 2, #63).
*
* The affordance and the arithmetic are `utils/swipe-to-queue.ts`,
* shared with both playlist detail views; what stays here is what
* only this list knows -- which row an event is on, and what a
* swipe on it means when other rows are selected.
*/
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.resolveTrackFromEvent(e);
/** How far along the row a swipe has to reach to mean it. */
private static readonly SWIPE_COMMIT_FRACTION = 0.3;
if (!hit) return null;
/** … and a floor, for a narrow list embedded in a detail page. */
private static readonly SWIPE_COMMIT_MIN_PX = 72;
/** How long the reveal holds its confirmation before snapping. */
private static readonly SWIPE_CONFIRM_MS = 550;
/** The snap itself, which the stylesheet also states. */
private static readonly SWIPE_SETTLE_MS = 180;
/** Which row is being swiped, and therefore which draws a reveal. */
@state() private swipeIndex: number | null = null;
/** Past the commit threshold: the reveal says so, in words. */
@state() private swipeArmed = false;
/** Committed, and holding the confirmation. */
@state() private swipeDone = false;
/** What the gesture did, for anyone not watching the row. */
@state() private swipeAnnouncement = '';
private swipeRow: HTMLElement | null = null;
private swipeKeys: string[] = [];
private swipeCommitPx = 0;
private swipeSettleTimer = 0;
return {
index: hit.index,
filePaths: this.swipeTargetKeys(hit.track.FilePath),
label: hit.track.TrackName,
};
},
repaint: () => this.virtualizer?.requestUpdate(),
});
private handleSelectAll = (): void => {
this.selection.selectAll();
@@ -1047,7 +1040,7 @@ export class TrackList
this.requestUpdate();
};
static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, css`
static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, swipeRevealStyles, css`
:host {
display: flex;
flex-direction: column;
@@ -1166,18 +1159,6 @@ export class TrackList
height: 33px;
box-sizing: border-box;
contain: strict;
/* Swipe right to queue (plan 019 phase 2, #63). Half of what
makes the gesture reach us on the device: auto lets Chrome
113's WebView 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. The cost is that a finger starting on a row can no longer
pan the shell sideways in the 600-899 band, where the shell
can still overflow; anywhere else on the page still can. */
touch-action: pan-y;
}
/* A phone row is two lines, and this height must equal
@@ -1256,61 +1237,6 @@ export class TrackList
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The reveal behind a swiped row (plan 019 phase 2, #63).
The row itself does not move -- its *cells* do. Moving the row
and counter-translating the pane inside it is the obvious
arrangement and does not work here: .track-row is contain:
strict with overflow: hidden, so a pane held at the row's
original position is a pane at a negative offset inside a
clipping box, and it is simply not painted. Sliding the cells
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.
It is not only a colour (WCAG 1.4.1, the rule the playing-row
marker is here for): the pane carries an icon and words, the
words change at the commit threshold, and the outcome is
announced in the list's live region. */
.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);
}
.track-row.swiping > :not(.swipe-reveal) {
transform: translateX(var(--yj-swipe-dx, 0px));
}
.track-row.settling > * {
transition:
transform 160ms ease-out,
width 160ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.track-row.settling > * {
transition: none;
}
}
.cell {
overflow: hidden;
@@ -1410,9 +1336,9 @@ export class TrackList
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
virt.removeEventListener('yj-tap', this.onRowTap);
virt.removeEventListener('yj-long-press', this.onRowLongPress);
virt.removeEventListener('yj-swipe-start', this.onRowSwipeStart);
virt.removeEventListener('yj-swipe-move', this.onRowSwipeMove);
virt.removeEventListener('yj-swipe-end', this.onRowSwipeEnd);
virt.removeEventListener('yj-swipe-start', this.swipe.onSwipeStart);
virt.removeEventListener('yj-swipe-move', this.swipe.onSwipeMove);
virt.removeEventListener('yj-swipe-end', this.swipe.onSwipeEnd);
virt.removeEventListener('dragstart', this.onDelegatedDragStart);
virt.removeEventListener('dragend', this.onTrackDragEnd);
}
@@ -1559,9 +1485,9 @@ export class TrackList
// through the same path a real click takes (plan 019).
virt.addEventListener('yj-tap', this.onRowTap);
virt.addEventListener('yj-long-press', this.onRowLongPress);
virt.addEventListener('yj-swipe-start', this.onRowSwipeStart);
virt.addEventListener('yj-swipe-move', this.onRowSwipeMove);
virt.addEventListener('yj-swipe-end', this.onRowSwipeEnd);
virt.addEventListener('yj-swipe-start', this.swipe.onSwipeStart);
virt.addEventListener('yj-swipe-move', this.swipe.onSwipeMove);
virt.addEventListener('yj-swipe-end', this.swipe.onSwipeEnd);
this.delegationAttached = true;
}
@@ -1881,10 +1807,6 @@ export class TrackList
this.virtualizer?.requestUpdate();
};
// =================================================================
// Swipe right to queue (plan 019 phase 2, #63)
// =================================================================
/**
* What a swipe on this row would queue.
*
@@ -1909,165 +1831,6 @@ export class TrackList
return [filePath];
}
private onRowSwipeStart = (e: SwipeEvent) => {
// 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 hit = this.resolveTrackFromEvent(e);
if (!hit) return;
const row = (e.target as HTMLElement).closest(
'.track-row',
) as HTMLElement | null;
if (!row) return;
e.preventDefault();
this.swipeRow = row;
this.swipeKeys = this.swipeTargetKeys(hit.track.FilePath);
// 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
// another.
this.swipeCommitPx = Math.max(
TrackList.SWIPE_COMMIT_MIN_PX,
row.getBoundingClientRect().width *
TrackList.SWIPE_COMMIT_FRACTION,
);
this.swipeArmed = false;
this.swipeDone = false;
this.swipeIndex = hit.index;
this.virtualizer?.requestUpdate();
this.setSwipeOffset(0);
};
private onRowSwipeMove = (e: SwipeEvent) => {
if (this.swipeIndex === null) return;
const dx = Math.min(
Math.max(e.detail.dx, 0),
this.swipeCommitPx * 2,
);
const armed = dx >= this.swipeCommitPx;
// Crossing the threshold is the only thing here that renders.
// The offset itself is written straight to the row's style, or
// a virtualized list would re-render every visible row for
// every frame of one finger's travel.
if (armed !== this.swipeArmed) {
this.swipeArmed = armed;
this.virtualizer?.requestUpdate();
}
this.setSwipeOffset(dx);
};
private onRowSwipeEnd = (e: SwipeEvent) => {
if (this.swipeIndex === null) return;
const commit =
!e.detail.canceled && e.detail.dx >= this.swipeCommitPx;
if (!commit) {
this.settleSwipe(0);
return;
}
queueStore.addTracksToQueue(this.swipeKeys);
const count = this.swipeKeys.length;
const only =
count === 1
? tracksByFilePath(this.tracks).get(this.swipeKeys[0]!)
: undefined;
// 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 snapping back the
// instant the finger lifts. The live region is the same
// sentence for anyone not watching it.
this.swipeDone = true;
this.swipeAnnouncement =
count === 1
? `Added ${only?.TrackName ?? 'the track'} to the queue.`
: `Added ${count} tracks to the queue.`;
this.virtualizer?.requestUpdate();
this.settleSwipe(TrackList.SWIPE_CONFIRM_MS);
};
/** Write the travel to the row itself, with no render. */
private setSwipeOffset(dx: number) {
this.swipeRow?.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
* `swipeIndex` is what decides who draws the reveal.
*/
private settleSwipe(delay: number) {
const row = this.swipeRow;
window.clearTimeout(this.swipeSettleTimer);
this.swipeSettleTimer = window.setTimeout(() => {
row?.classList.add('settling');
this.setSwipeOffset(0);
this.swipeSettleTimer = window.setTimeout(() => {
row?.classList.remove('settling');
row?.style.removeProperty('--yj-swipe-dx');
this.swipeRow = null;
this.swipeIndex = null;
this.swipeArmed = false;
this.swipeDone = false;
this.virtualizer?.requestUpdate();
}, TrackList.SWIPE_SETTLE_MS);
}, delay);
}
/**
* What is revealed behind the row, in three states.
*
* One glyph throughout, and the words carry the state. A tick
* would read better for the last of them and is `ICON_IN_LIBRARY`
* -- it means *you own this* -- and `icon-language.ts` exists
* because `plus` came to mean four things that way.
*/
private renderSwipeReveal() {
const count = this.swipeKeys.length;
const what =
count === 1 ? 'to queue' : `${count} tracks to queue`;
return html`
<div
class=${classMap({
'swipe-reveal': true,
armed: this.swipeArmed,
})}
aria-hidden="true"
data-testid="swipe-reveal"
>
<wa-icon name=${ICON_QUEUE}></wa-icon>
<span
>${this.swipeDone
? 'Added'
: this.swipeArmed
? 'Release to add'
: `Add ${what}`}</span
>
</div>
`;
}
private onDelegatedDragStart = (e: DragEvent) => {
const hit = this.resolveTrackFromEvent(e);
@@ -2489,7 +2252,7 @@ export class TrackList
'track-row': true,
active,
selected,
swiping: this.swipeIndex === index,
swiping: this.swipe.isSwiping(index),
})}
role="row"
aria-rowindex=${index + 1}
@@ -2499,9 +2262,10 @@ export class TrackList
draggable="true"
data-index=${index}
data-testid="track-row"
data-swipe
data-file-path=${track.FilePath}
>
${this.swipeIndex === index ? this.renderSwipeReveal() : nothing}
${this.swipe.renderReveal(index)}
<div
role="gridcell"
class=${classMap({
@@ -2649,7 +2413,7 @@ export class TrackList
${this.liveStatus(visibleTracks.length)}
</div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipeAnnouncement}
${this.swipe.announcement}
</div>
${this.tracks.length === 0
? this.renderPlaceholder()
+362
View File
@@ -0,0 +1,362 @@
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;
}
}
`;