Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82d29abd7 | ||
|
|
d365850321 | ||
|
|
3a2d3e8ef8 | ||
|
|
871a3b7aac | ||
|
|
f3207e8bf9 | ||
|
|
772c71c49f | ||
|
|
c56eae2959 | ||
|
|
e5d0f2714b | ||
|
|
ee1d8b3179 | ||
|
|
29feb4b94b | ||
|
|
4e667759c4 | ||
|
|
ff3875b55d |
@@ -158,7 +158,7 @@ only climb when it cannot.
|
|||||||
| You changed | Run | Cost |
|
| You changed | Run | Cost |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| A Lit component, a store, the shortcut service | `make ui-test` | ~2 s, no app |
|
| A Lit component, a store, the shortcut service | `make ui-test` | ~2 s, no app |
|
||||||
| …and it renders differently | `make ui-visual` | + 6 baselines, opt-in |
|
| …and it renders differently | `make ui-visual` | + 10 baselines, opt-in, never gates |
|
||||||
| Any Go code | `make test` | 3 passes, ~2 min |
|
| Any Go code | `make test` | 3 passes, ~2 min |
|
||||||
| A service that emits events | `make test` — assert on the payload, see `backend/queue/emit_test.go` | in-process, no app |
|
| A service that emits events | `make test` — assert on the payload, see `backend/queue/emit_test.go` | in-process, no app |
|
||||||
| A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s |
|
| A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s |
|
||||||
@@ -180,6 +180,13 @@ less than it looks.)
|
|||||||
|
|
||||||
Two rules about climbing:
|
Two rules about climbing:
|
||||||
|
|
||||||
|
- **If you moved a component's geometry, run `make ui-visual` and
|
||||||
|
refresh that component's baseline in the same commit.** Nothing else
|
||||||
|
will: it is the one tier in this repo no hook and no CI job runs, and
|
||||||
|
it cannot be one — its references are machine-specific, measured in
|
||||||
|
[references/ui-tier.md](references/ui-tier.md). Four of them drifted
|
||||||
|
across three merges before anyone noticed (#196). Read the image;
|
||||||
|
never bless a reference you did not cause.
|
||||||
- **A component test passing is not the app rendering.** If you touched
|
- **A component test passing is not the app rendering.** If you touched
|
||||||
anything in `frontend/src`, verify it in the real app too — start it
|
anything in `frontend/src`, verify it in the real app too — start it
|
||||||
headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*.
|
headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*.
|
||||||
|
|||||||
@@ -78,9 +78,58 @@ synchronously.
|
|||||||
Microtasks and not a timer, deliberately: a timer hangs forever under
|
Microtasks and not a timer, deliberately: a timer hangs forever under
|
||||||
the suites that install fake ones.
|
the suites that install fake ones.
|
||||||
|
|
||||||
Visual baselines are font-hinting and compositing sensitive, which is
|
## The visual tier does not gate, and that is measured (#196)
|
||||||
why they are opt-in: they only mean anything on the machine that
|
|
||||||
recorded them.
|
`make ui-visual` is the same suite with nine `toMatchScreenshot`
|
||||||
|
baselines switched on. **Nothing runs it but a person**, deliberately,
|
||||||
|
and the reason is a number rather than a preference: the committed
|
||||||
|
baselines were recorded on Arch, and replayed in a bare `ubuntu:24.04`
|
||||||
|
container — CI's `check` image — three of them fail for reasons that
|
||||||
|
have nothing to do with any component.
|
||||||
|
|
||||||
|
| baseline | Arch | ubuntu:24.04 |
|
||||||
|
|---|---|---|
|
||||||
|
| `page-header` filtered-by-search | passes | ratio 0.03 differ, against a 0.02 allowance |
|
||||||
|
| `track-info` | passes | ratio 0.03 differ |
|
||||||
|
| `seek-bar` | 1152×18 | 1152×17 |
|
||||||
|
|
||||||
|
The two references that were genuinely stale did not even agree about
|
||||||
|
their *new* size — `now-playing` renders 1152×65 on Arch and 1152×64 in
|
||||||
|
the container. So moving CI's `check` job from `make ui-test` to
|
||||||
|
`make ui-visual` is not a one-line change: it needs a second,
|
||||||
|
container-recorded baseline set, which every local run would then fail
|
||||||
|
against. That is the same trap the other way round, and a pre-push hook
|
||||||
|
is the same fault again — one machine's baselines against everybody
|
||||||
|
else's renderer.
|
||||||
|
|
||||||
|
So the tier stays local and opt-in, and the rule that replaces the gate
|
||||||
|
is:
|
||||||
|
|
||||||
|
- **A change that moves a component's geometry refreshes that
|
||||||
|
component's reference in the same commit, having read the image.**
|
||||||
|
Look at the PNG; the dimensions in the failure message are the cheap
|
||||||
|
half of the answer.
|
||||||
|
- **Never refresh a reference you did not cause.** #196 exists because
|
||||||
|
four of them drifted across three unrelated merges, and every red run
|
||||||
|
made the next person likelier to stop running the tier than to read
|
||||||
|
it.
|
||||||
|
- **State the world the shot is taken in.** The stores are singletons,
|
||||||
|
so a visual case that sets nothing photographs whatever the previous
|
||||||
|
case left behind — which is how the sidebar's baseline came to have
|
||||||
|
Tracks lit and `now-playing`'s to be playing from a dynamic mix.
|
||||||
|
- **Record one file with `make ui-visual-update UI_ARGS=<path>`**, and
|
||||||
|
check `git status` before committing either way. That filter is only
|
||||||
|
honoured since #204: the recipe was a bare `--update`, and vitest
|
||||||
|
takes the following positional as the flag's value, so the path was
|
||||||
|
swallowed and *every* baseline was re-recorded — blessing any stale
|
||||||
|
one in silence.
|
||||||
|
|
||||||
|
What the tier is worth, for the record: it is a *layout* check, blind to
|
||||||
|
colour (the component tier has no `:root`, so it renders the fallbacks —
|
||||||
|
`make ui-visual` passed unchanged through a whole palette rewrite,
|
||||||
|
twice), and it has caught one thing nothing else could — swapping
|
||||||
|
`library-status-indicator`'s `<button>` for a `<span>` lost the UA
|
||||||
|
stylesheet's `box-sizing` and grew the badge 36→38px.
|
||||||
|
|
||||||
## Bindings
|
## Bindings
|
||||||
|
|
||||||
|
|||||||
+169
-14
@@ -5,7 +5,7 @@
|
|||||||
**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54
|
**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54
|
||||||
(native feel), #5/#8 (selection, drag to queue — the desktop semantics
|
(native feel), #5/#8 (selection, drag to queue — the desktop semantics
|
||||||
being diverged from)
|
being diverged from)
|
||||||
**Status:** in flight.
|
**Status:** shipped (phases 1-4). Its one deliberate remainder is #200.
|
||||||
|
|
||||||
#73 puts #60 first in Phase 4 because it is "the presentation every
|
#73 puts #60 first in Phase 4 because it is "the presentation every
|
||||||
other item needs", and this is the next one. The Direction on #63 asks
|
other item needs", and this is the next one. The Direction on #63 asks
|
||||||
@@ -225,29 +225,184 @@ still works. `SelectionController` gains a mode. `track-list` acts on
|
|||||||
tap and enters the mode on long press. The action bar.
|
tap and enters the mode on long press. The action bar.
|
||||||
|
|
||||||
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
|
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
|
||||||
finding above and a reveal-and-snap affordance.
|
finding above and a reveal-and-snap affordance. **Shipped**; what the
|
||||||
|
device said about it is the section below.
|
||||||
|
|
||||||
**Phase 3 — the other three surfaces**, which is mostly wiring, since
|
**Phase 3 — the other three surfaces**, which is mostly wiring, since
|
||||||
they already share the controller.
|
they already share the controller. **Shipped**, and it was not entirely
|
||||||
|
wiring — see below.
|
||||||
|
|
||||||
**Phase 4 — what this leaves behind.** The inline `explore-link`s in a
|
**Phase 4 — what this leaves behind.** The inline `explore-link`s in a
|
||||||
row are a single-click target inside a row whose single tap now plays;
|
row are a single-click target inside a row whose single tap now plays;
|
||||||
that conflict is #67's, and this plan should not pre-empt its answer
|
that conflict is #67's, and this plan should not pre-empt its answer
|
||||||
beyond making tap-to-play win on touch.
|
beyond making tap-to-play win on touch. **Shipped.**
|
||||||
|
|
||||||
|
## Phase 3 was not symmetric, in two places
|
||||||
|
|
||||||
|
**A tap on a queue row plays that position**, not the list. 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 a user had inserted by hand. It reads as a
|
||||||
|
no-op and is not one.
|
||||||
|
|
||||||
|
**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 —
|
||||||
|
the fault `utils/icon-language.ts` exists to have fixed for glyphs.
|
||||||
|
Removing a queue row is on the row itself (the ×), on its bottom sheet
|
||||||
|
since #60, and on the selection bar this phase gave it. The assertion
|
||||||
|
is that its rows do **not** carry `data-swipe`, so a swipe there cannot
|
||||||
|
silently become a second meaning for the app's one horizontal gesture.
|
||||||
|
|
||||||
|
And the affordance became `utils/swipe-to-queue.ts` rather than being
|
||||||
|
copied twice. Three lists want it; 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 shared stylesheet is keyed on `[data-swipe]`
|
||||||
|
rather than on a class name, because the three lists call their rows
|
||||||
|
two different things and the `touch-action` half of the device fix has
|
||||||
|
to reach all of them.
|
||||||
|
|
||||||
|
## Phase 4 was already true, which is why it is asserted
|
||||||
|
|
||||||
|
A claimed tap has its click swallowed at document capture, so an
|
||||||
|
`explore-link` inside the row never sees one and tap-to-play wins with
|
||||||
|
no rule of its own. Nothing in the suite would have failed if that
|
||||||
|
stopped covering the link, and the symptom — tapping 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.
|
||||||
|
|
||||||
|
**Its test was vacuous when written**, in the way this file keeps
|
||||||
|
finding: the tap helper dispatched `pointerdown` and `pointerup` and no
|
||||||
|
`click`, so there was nothing to swallow and the assertion held on any
|
||||||
|
build. It sends the trailing click now, which also strengthened phase
|
||||||
|
1's "a tap plays and does not also select". The fixture needed an MBID
|
||||||
|
for the same reason — without one the link asks the backend for a local
|
||||||
|
album first and gives up when nothing answers, so "it did not navigate"
|
||||||
|
was true of a working build and a broken one alike.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## What phase 2 measured, which was not what phase 2 predicted
|
||||||
|
|
||||||
|
The `touch-action` finding above is **half** of the answer, and
|
||||||
|
shipping only that half would have been the exact failure it warns
|
||||||
|
about. Driving a real finger with `adb shell input swipe` across a
|
||||||
|
track row, three values, all three on the device:
|
||||||
|
|
||||||
|
```
|
||||||
|
touch-action: auto pointerdown, 1 move, pointercancel
|
||||||
|
touch-action: pan-y pointerdown, 2 moves, pointercancel
|
||||||
|
touch-action: none pointerdown, 2 moves, pointercancel
|
||||||
|
```
|
||||||
|
|
||||||
|
`touchmove` kept firing in all three. So **Chrome 113's WebView
|
||||||
|
cancels the pointer stream ~16px into any drag whatever `touch-action`
|
||||||
|
says**, and a swipe recognised from `pointermove` — which is what the
|
||||||
|
rest of this module is built on — is a swipe that dies 16px in.
|
||||||
|
|
||||||
|
The other half is a **non-passive `touchmove` calling
|
||||||
|
`preventDefault()`**: with it, the same swipe ran to 12 moves and a
|
||||||
|
`pointerup` at full travel. And both halves are required, which was
|
||||||
|
measured rather than assumed — with the `preventDefault` in place and
|
||||||
|
`touch-action` back at `auto`, the gesture died after **one** move.
|
||||||
|
The reading is that `auto` lets the browser commit to a horizontal pan
|
||||||
|
on the first move past slop, before any threshold of ours can have
|
||||||
|
been crossed, while `pan-y` leaves it undecided long enough for the
|
||||||
|
second move to claim it.
|
||||||
|
|
||||||
|
`none` is the one value to avoid: the list stopped scrolling at all.
|
||||||
|
With the shipped pair, a vertical drag still scrolls the virtualizer
|
||||||
|
81px on the same run that a horizontal one survives.
|
||||||
|
|
||||||
|
**`draggable="true"` is not a competitor**, which is the other thing
|
||||||
|
the device was asked. No `dragstart` fires from a touch drag on this
|
||||||
|
WebView at all, so the drag-to-playlist attribute on every row needs no
|
||||||
|
pointer-type gate.
|
||||||
|
|
||||||
|
### And it found a phase 1 defect that no tier can see
|
||||||
|
|
||||||
|
The native `contextmenu` arrives in **either** order, and phase 1 only
|
||||||
|
handled one of them. `nativeSeen` covers the browser's menu arriving
|
||||||
|
*during* the hold. The reverse — our 500ms timer firing first, a
|
||||||
|
component claiming it, and Chrome delivering its own `contextmenu`
|
||||||
|
50–70ms *later* — was suppressed by nothing, so the context menu
|
||||||
|
opened on top of the selection bar. Measured over four holds:
|
||||||
|
|
||||||
|
```
|
||||||
|
hold 1 yj-long-press, then contextmenu isTrusted=true menu open
|
||||||
|
hold 2 yj-long-press clean
|
||||||
|
hold 3 yj-long-press, then contextmenu isTrusted=true menu open
|
||||||
|
hold 4 yj-long-press clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Two in four, on the one surface #63 exists to have changed, and
|
||||||
|
invisible to both browser tiers because neither synthesises a
|
||||||
|
`contextmenu` from a dispatched press. A press that has produced its
|
||||||
|
outcome now suppresses a late one whichever branch it took; six holds
|
||||||
|
on the fixed build, six clean.
|
||||||
|
|
||||||
|
### The rules phase 2 settled
|
||||||
|
|
||||||
|
- **A swipe is not a selection.** It queues the row it was made on,
|
||||||
|
unless that row is one of several *explicitly* selected — the same
|
||||||
|
rule the context menu answers with, because a bar reading "40
|
||||||
|
selected" beside a gesture that quietly queues one of them is two
|
||||||
|
answers to one question. It never changes the selection, which is
|
||||||
|
where it differs from a right-click.
|
||||||
|
- **Rightward only.** Nothing is bound to a leftward swipe and
|
||||||
|
claiming one would take a gesture away to do nothing with it.
|
||||||
|
- **The commit threshold is a fraction of the row** (0.3, floor 72px),
|
||||||
|
because the row is 424x52 on this device and a bare pixel count is a
|
||||||
|
fraction of a row height on one screen and a third of the width on
|
||||||
|
the next.
|
||||||
|
- **The affordance 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 ("Add to queue" → "Release
|
||||||
|
to add" → "Added"), and the outcome is announced in a live region.
|
||||||
|
- **The row does not move; its cells do.** `.track-row` is
|
||||||
|
`contain: strict` with `overflow: hidden`, so a pane held at the
|
||||||
|
row's original position while the row translates is a pane at a
|
||||||
|
negative offset inside a clipping box and is simply not painted.
|
||||||
|
Sliding the cells needs no wrapper element in a row that is already
|
||||||
|
a grid.
|
||||||
|
- **The travel is written to the row's own style, not rendered.** One
|
||||||
|
render at the start, one at the threshold, one at the end; a
|
||||||
|
virtualizer re-rendering every visible row per frame of one finger's
|
||||||
|
travel is the thing `perf.m1` is about.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
1. **Does selection mode have an escape other than the bar's own
|
1. **Does selection mode have an escape other than the bar's own
|
||||||
close?** Back is the platform's answer and the shell already owns
|
close?** *Settled: Escape, here; back, not here.*
|
||||||
the history stack (#6/#55). Pushing an entry for a *mode* rather
|
|
||||||
than a place is the same argument #55 settled for the overlaid
|
Escape leaves the mode, from `selection-bar` rather than from each
|
||||||
queue, and it should probably be settled the same way — but the
|
of the four hosts — that element exists only while the mode does, so
|
||||||
queue is a screen and a selection mode is not, so it wants its own
|
it is the one place a dismissal can be attached and detached with
|
||||||
paragraph rather than an assumption.
|
the thing it dismisses. It is the same documented exception the
|
||||||
|
overlaid queue's Escape is: **a dismissal, not a shortcut**, so it
|
||||||
|
is not a panel-scoped binding.
|
||||||
|
|
||||||
|
The back gesture is the half that is *not* done, and deliberately.
|
||||||
|
The obvious version — `selection-bar` pushing a history entry — is
|
||||||
|
precisely the fault `navStack` was deleted for: the shell owns the
|
||||||
|
stack (#6/#55) and is the only thing that calls `pushState`, so that
|
||||||
|
two stacks cannot disagree about what one press means. Four lists
|
||||||
|
each reaching for `history` is four stacks. It is also wrong on its
|
||||||
|
own terms, since a mode is per-component and a user who enters one,
|
||||||
|
navigates away and returns has an entry for a mode that no longer
|
||||||
|
exists. #55 settled the shape for a *place*; a mode is not one,
|
||||||
|
which is why it could not simply inherit that answer.
|
||||||
|
|
||||||
|
What it wants is one shell-owned register of dismissible surfaces,
|
||||||
|
which would retro-fit the queue overlay, the dialogs and this alike
|
||||||
|
rather than adding a fourth private answer. **#200.**
|
||||||
|
|
||||||
2. **Does a tap on a row's favourite icon still toggle it in normal
|
2. **Does a tap on a row's favourite icon still toggle it in normal
|
||||||
mode?** It is inside the row and the row now plays. It has to keep
|
mode?** *Settled in phase 1: yes.* A control inside the row keeps
|
||||||
working — it is a 44px target since #56 — so the gesture layer needs
|
its own tap — the gesture is simply not claimed there, so the click
|
||||||
the same "a control inside the row wins" rule the keyboard service
|
behind it falls through untouched. It is the same rule the shortcut
|
||||||
has for a focused control that owns a key.
|
service has for a focused control that owns a key, and it is what
|
||||||
|
keeps the 44px favourite target (#56) from becoming a 44px play
|
||||||
|
target. The queue row's × is the second instance of it.
|
||||||
@@ -262,6 +262,26 @@ real store code. A binding carries an **ID**, not a name
|
|||||||
`yellowjacket/backend/home.Service.GetShelves`), so the fake derives
|
`yellowjacket/backend/home.Service.GetShelves`), so the fake derives
|
||||||
that map from the generated tree rather than writing it down.
|
that map from the generated tree rather than writing it down.
|
||||||
|
|
||||||
|
**A test file does not get its own origin, so `setup.ts` clears
|
||||||
|
`localStorage` between tests.** `@vitest/browser-playwright` opens one
|
||||||
|
BrowserContext per session and runs several files in it one after
|
||||||
|
another, so everything a component persists — the track list's sort and
|
||||||
|
column widths, the cover size, `now-playing`'s scroll mode — is still
|
||||||
|
there when the next file mounts the same component. Which files share a
|
||||||
|
tab, and in what order, changes run to run, so the symptom is a spec
|
||||||
|
that fails about one test in three and passes every time it is run on
|
||||||
|
its own: #138 cost three scheduled runs, one of them a PR whose diff
|
||||||
|
held no frontend code at all. Measured on the build before the fix, a
|
||||||
|
single full run started **24** tests with storage already set. Two
|
||||||
|
things follow. The clear is safe precisely because the leak is
|
||||||
|
sequential — files in a session do not overlap, so it cannot wipe
|
||||||
|
storage a concurrently-running file is in the middle of using — and it
|
||||||
|
belongs in `setup.ts` rather than in the specs that write, because the
|
||||||
|
spec that *reads* is never the one that knows. And a spec whose
|
||||||
|
assertion depends on an order still **states that order** rather than
|
||||||
|
inheriting a default, or the next change to a default is the same
|
||||||
|
mystery again.
|
||||||
|
|
||||||
**`frontend/bindings/` is generated by `wails3`, not `go generate`**, so
|
**`frontend/bindings/` is generated by `wails3`, not `go generate`**, so
|
||||||
the pre-commit codegen check does not cover it. `make bindings-check`
|
the pre-commit codegen check does not cover it. `make bindings-check`
|
||||||
(~3.5 s warm, ~20 s on a cold build cache, also a pre-commit hook)
|
(~3.5 s warm, ~20 s on a cold build cache, also a pre-commit hook)
|
||||||
@@ -1449,18 +1469,112 @@ never opens). **The sweep found two of the fourteen**; twelve were
|
|||||||
converted by hand.
|
converted by hand.
|
||||||
|
|
||||||
**And a menu opens from a finger, through the event it already has.**
|
**And a menu opens from a finger, through the event it already has.**
|
||||||
`utils/long-press.ts` is one document-capture listener installed once
|
`utils/touch-gestures.ts` is one document-capture listener installed
|
||||||
from `index.ts`: a touch that holds still for 500 ms dispatches a
|
once from `index.ts` — `utils/long-press.ts` until #63 replaced it,
|
||||||
synthetic `contextmenu` at the touch point, so all six components that
|
rather than adding a second listener claiming the same 500 ms hold. It
|
||||||
bind one — delegated on a virtualizer, per row, per card — gained the
|
**announces** rather than acts: `yj-tap`, `yj-long-press` and
|
||||||
gesture without changing. The target is `composedPath()[0]` rather than
|
`yj-swipe-start` are composed and cancelable, and a component claims
|
||||||
|
one with `preventDefault()`. That is what let #63 reassign the hold
|
||||||
|
without touching one of the fourteen context menus: an *unclaimed*
|
||||||
|
`yj-long-press` still becomes a synthetic `contextmenu`, so all six
|
||||||
|
components that bind one — delegated on a virtualizer, per row, per
|
||||||
|
card — behave exactly as they did, and only the lists that opt in get
|
||||||
|
selection mode. The target is `composedPath()[0]` rather than
|
||||||
`elementFromPoint`, which stops at the outermost shadow host and so
|
`elementFromPoint`, which stops at the outermost shadow host and so
|
||||||
reaches a delegated listener and no per-row one; a browser that fires
|
reaches a delegated listener and no per-row one; and the click that
|
||||||
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
|
ends a *claimed* gesture is swallowed, keyed on the gesture rather than
|
||||||
vary) wins, ours being told from theirs by **identity** rather than
|
on a time window so the first tap on the menu it opened is not eaten
|
||||||
`isTrusted`, since no test can dispatch a trusted event; and the click
|
too.
|
||||||
that ends the gesture is swallowed, keyed on the gesture rather than on
|
|
||||||
a time window so the first tap on the menu it opened is not eaten too.
|
Three things about it are load-bearing, and all three were found on the
|
||||||
|
device rather than in a tier.
|
||||||
|
|
||||||
|
**A browser that fires its own long-press `contextmenu` is a trigger,
|
||||||
|
not a competitor.** Chromium does, WebKit and the WebView vary. The old
|
||||||
|
rule was to stand down when a trusted one arrived, which was right
|
||||||
|
while both paths ended in a context menu and is wrong the moment a hold
|
||||||
|
can mean something else — standing down silently does the *old* thing.
|
||||||
|
So the gesture is announced from the native event, and only a component
|
||||||
|
that claims it suppresses that event. Ours and the browser's are told
|
||||||
|
apart by **identity** rather than `isTrusted`, since no test can
|
||||||
|
dispatch a trusted event.
|
||||||
|
|
||||||
|
**That arrives in either order, and both have to be handled.** The
|
||||||
|
native `contextmenu` mid-hold is one case; the other is our own 500 ms
|
||||||
|
timer firing first and Chrome delivering its menu **50–70 ms later**,
|
||||||
|
which nothing suppressed — measured over four holds on the reference
|
||||||
|
phone, two took that order, so the context menu opened over the
|
||||||
|
selection bar intermittently, on the one surface #63 changed. A press
|
||||||
|
that has produced its outcome therefore suppresses a late
|
||||||
|
`contextmenu` whichever branch it took.
|
||||||
|
|
||||||
|
**A horizontal swipe runs on touch events, and needs two things that
|
||||||
|
look like one.** Chrome 113's WebView cancels the *pointer* stream
|
||||||
|
~16 px into any drag — measured at `auto`, `pan-y` and `none` alike,
|
||||||
|
one or two `pointermove`s and then `pointercancel`, while `touchmove`
|
||||||
|
kept firing throughout. So the recogniser is `touchmove`, the surface
|
||||||
|
declares **`touch-action: pan-y`** *and* a claimed swipe calls
|
||||||
|
**`preventDefault()`** on a non-passive listener. Neither works alone:
|
||||||
|
with the `preventDefault` in place but `touch-action` back at `auto`
|
||||||
|
the gesture died after one move, because `auto` lets the browser commit
|
||||||
|
to a horizontal pan before any threshold can be crossed. `none` is the
|
||||||
|
value to avoid — it takes the list's own vertical scrolling with it.
|
||||||
|
**Both are correct in Chromium either way**, which is why this is
|
||||||
|
written down rather than tested. The tie breaks toward scrolling, in
|
||||||
|
that order: vertical drift past the tolerance vetoes the swipe for the
|
||||||
|
rest of the press (a scroll that curves is still a scroll), and a
|
||||||
|
gesture that is not *strictly* more horizontal than vertical is the
|
||||||
|
scroller's.
|
||||||
|
|
||||||
|
**And what a finger *means* on a row is the inversion of what a mouse
|
||||||
|
means, decided per event** (#63). A click selects and a double-click
|
||||||
|
plays; a tap **plays** and a hold enters **selection mode**, in which a
|
||||||
|
tap toggles. The predicate is `pointerType`, never a viewport width and
|
||||||
|
never a platform flag — #64's rule, and with #64's warning: keyed on a
|
||||||
|
width, an Android tablet over 600px gets desktop semantics on a
|
||||||
|
touchscreen, a touchscreen laptop cannot be described at all, and a
|
||||||
|
narrow desktop window gets phone semantics with a mouse.
|
||||||
|
|
||||||
|
There is deliberately **no double-tap**, which #63 asked for. The first
|
||||||
|
tap of one is indistinguishable from a single tap until the interval
|
||||||
|
expires, so tapping would have to wait `DOUBLE_CLICK_GRACE_MS` before
|
||||||
|
acting — 250ms on top of a measured ~100ms play, 3.5x the app's primary
|
||||||
|
interaction, to reach a menu a hold already reaches. So the menu and
|
||||||
|
the action bar are the same surface: `components/selection-bar/` is
|
||||||
|
presentational (a count and a list of actions, no store, no selection),
|
||||||
|
`SelectionController` carries the mode for all four selecting surfaces,
|
||||||
|
and #60's bottom sheet is the overflow behind "More" — so
|
||||||
|
`contextMenuStyles`, `MenuKeyboard` and `menu-surface` are reused
|
||||||
|
rather than reimplemented.
|
||||||
|
|
||||||
|
Three things about it are load-bearing. **A control inside a row keeps
|
||||||
|
its own tap**: the gesture is simply not claimed there, so the click
|
||||||
|
behind it falls through, which is what stops the 44px favourite target
|
||||||
|
(#56) becoming a 44px play target — the queue row's × is the second
|
||||||
|
instance. **A swipe right queues**, and its affordance is
|
||||||
|
`utils/swipe-to-queue.ts` once rather than in each of the three lists
|
||||||
|
that draw it: the row does not move, its *children* do (a row here is
|
||||||
|
`contain: strict` with `overflow: hidden`, so a pane held at the row's
|
||||||
|
original position while the row translates is at a negative offset
|
||||||
|
inside a clipping box and is not painted), the travel is written to the
|
||||||
|
row's own style rather than rendered, and the threshold is a fraction
|
||||||
|
of the row because the row is 424x52 on the reference device. **The
|
||||||
|
queue panel takes the tap and the hold and refuses the swipe**, because
|
||||||
|
a right swipe means *add to the queue* everywhere it exists and a queue
|
||||||
|
row is already in it — the only thing it could mean there is *remove*,
|
||||||
|
which is the same gesture with the opposite effect one screen away.
|
||||||
|
A tap there plays that *position*, too: setting the queue to the queue
|
||||||
|
reads as a no-op and discards its source, its shuffle order and
|
||||||
|
anything inserted by hand.
|
||||||
|
|
||||||
|
Escape leaves the mode, from `selection-bar` rather than from each
|
||||||
|
host, since that element exists only while the mode does — the same
|
||||||
|
exception the overlaid queue's Escape is, *a dismissal, not a
|
||||||
|
shortcut*. The platform's back gesture deliberately does **not** reach
|
||||||
|
it: the shell owns the history stack and four lists each reaching for
|
||||||
|
`history` is four stacks, which is the fault `navStack` was deleted
|
||||||
|
for. That wants one shell-owned register of dismissible surfaces, which
|
||||||
|
is #200.
|
||||||
|
|
||||||
**A control revealed by `:hover` is gated on the device having hover,
|
**A control revealed by `:hover` is gated on the device having hover,
|
||||||
and which way round depends on whether it is the only route to its
|
and which way round depends on whether it is the only route to its
|
||||||
@@ -2455,11 +2569,38 @@ Five things about it are load-bearing, and four of them fail silently:
|
|||||||
correctly. Confidently wrong is worse than absent here, which is the
|
correctly. Confidently wrong is worse than absent here, which is the
|
||||||
same rule `Known` exists for.
|
same rule `Known` exists for.
|
||||||
|
|
||||||
One gap this did not close, and it is older: **`dhowden/tag` has no
|
One gap this did not close and #104 did: **`dhowden/tag` has no RIFF
|
||||||
RIFF reader**, so nothing the tag writer puts in a WAV's `id3 ` chunk
|
reader**, so nothing the tag writer put in a WAV's `id3 ` chunk was
|
||||||
is visible to `metadata.ExtractTags` — not the totals and not the title
|
visible to `metadata.ExtractTags` — not the totals and not the title
|
||||||
either. `wav_test.go` reads that chunk itself, which is why no test
|
either, on files the app itself had just tagged. `wav_test.go` read
|
||||||
ever noticed.
|
that chunk itself, which is why no test noticed: a round trip asserted
|
||||||
|
through the writer's own parser is a test of the writer.
|
||||||
|
|
||||||
|
`backend/riff` is where the container is now read, and it is its own
|
||||||
|
package because the alternative is an import cycle — `tagwriter`
|
||||||
|
imports `metadata`, so `metadata` cannot reach back for `parseRIFF`.
|
||||||
|
`backend/tagtotals` is the precedent.
|
||||||
|
|
||||||
|
Three things about it are load-bearing. **The two readers are
|
||||||
|
deliberately different**: `Parse` holds every chunk in memory, which is
|
||||||
|
what rewriting a file needs, and a WAV's audio *is* a chunk — so the
|
||||||
|
scan path uses `ID3Chunk`, which seeks over what it is not looking for.
|
||||||
|
**The container decides, before `tag.ReadFrom` rather than after it
|
||||||
|
fails**, because that library's last resort is an ID3v1 trailer and a
|
||||||
|
WAV carrying both would otherwise be read by the wrong one. And **an
|
||||||
|
untagged WAV is a file with no tags, not a file with a problem**: no
|
||||||
|
chunk, an RF64 container or a tag holding no frames all read as empty
|
||||||
|
metadata with no `TagReadWarning`, since the scanner's filename
|
||||||
|
fallback is the right answer and a warning would put a fault on a file
|
||||||
|
that has none.
|
||||||
|
|
||||||
|
The gap was pinned by a test that said so, which failed the moment the
|
||||||
|
reader learned and carried the instructions for what to update in its
|
||||||
|
own comment. So it is deleted, `TestFixturesMatchManifest` no longer
|
||||||
|
skips `wav`, and `totals_test.go`'s WAV case goes through
|
||||||
|
`metadata.ExtractTags` like the other three formats. The fixture
|
||||||
|
library's two WAV tracks scan with their tags and their cover now,
|
||||||
|
which is a change to what every seeded tier sees.
|
||||||
|
|
||||||
**The absence is what gets marked, not the presence.** The tracklist
|
**The absence is what gets marked, not the presence.** The tracklist
|
||||||
put a green tick against every owned track and a legend underneath
|
put a green tick against every owned track and a legend underneath
|
||||||
@@ -3330,6 +3471,25 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
|
|||||||
Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same
|
Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same
|
||||||
`applySchema` production uses so the two cannot diverge. Test audio fixtures live in `test_data/music_library_test/`. Table-driven tests are the norm.
|
`applySchema` production uses so the two cannot diverge. Test audio fixtures live in `test_data/music_library_test/`. Table-driven tests are the norm.
|
||||||
|
|
||||||
|
**`make ui-visual` is the one tier nothing but a person runs, and it
|
||||||
|
cannot become one.** Its ten `toMatchScreenshot` baselines were recorded
|
||||||
|
on a developer's Arch box; replayed in a bare `ubuntu:24.04` container
|
||||||
|
— CI's `check` image — three of them fail on font metrics and
|
||||||
|
compositing alone (`track-info` and one `page-header` shot at a 0.03
|
||||||
|
mismatch ratio against a 0.02 allowance, `seek-bar` one pixel shorter),
|
||||||
|
and two components disagree about their own height between the two
|
||||||
|
machines. So CI keeps running `make ui-test`, which is the same suite
|
||||||
|
with the comparisons off, and a pre-push hook would be the same fault
|
||||||
|
with the machines swapped. What replaces the gate is a rule, in
|
||||||
|
`.pi/skills/yellowjacket-dev/references/ui-tier.md`: **a change that
|
||||||
|
moves a component's geometry refreshes that component's baseline in the
|
||||||
|
same commit, having read the image, and never one it did not cause**.
|
||||||
|
That is #196, which was four stale references accumulated across three
|
||||||
|
unrelated merges — a red tier nobody could read, which is how it stayed
|
||||||
|
red. A visual case must also **state the world it photographs**, since
|
||||||
|
the stores are singletons and a case that sets nothing records whatever
|
||||||
|
the previous one left in them.
|
||||||
|
|
||||||
## Git Workflow
|
## Git Workflow
|
||||||
|
|
||||||
Feature branches and PRs are the only way in: **`main` is a protected
|
Feature branches and PRs are the only way in: **`main` is a protected
|
||||||
|
|||||||
@@ -160,8 +160,11 @@ ui-watch: ## Same suite, in watch mode
|
|||||||
ui-visual: ## Run the suite including screenshot comparisons
|
ui-visual: ## Run the suite including screenshot comparisons
|
||||||
@cd frontend && YJ_VISUAL=1 npx vitest run $(UI_ARGS)
|
@cd frontend && YJ_VISUAL=1 npx vitest run $(UI_ARGS)
|
||||||
|
|
||||||
ui-visual-update: ## Re-record the screenshot baselines
|
# `--update=true`, never a bare `--update`: vitest takes the following
|
||||||
@cd frontend && YJ_VISUAL=1 npx vitest run --update $(UI_ARGS)
|
# positional as the flag's value, so `--update <path>` swallows the path
|
||||||
|
# and re-records every baseline in the repo instead of the one named.
|
||||||
|
ui-visual-update: ## Re-record the screenshot baselines (UI_ARGS=<path> to filter)
|
||||||
|
@cd frontend && YJ_VISUAL=1 npx vitest run --update=true $(UI_ARGS)
|
||||||
|
|
||||||
ui-setup: ## Install the Vitest browser provider's own Chromium (once)
|
ui-setup: ## Install the Vitest browser provider's own Chromium (once)
|
||||||
@cd frontend && pnpm install && npx playwright install chromium
|
@cd frontend && pnpm install && npx playwright install chromium
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ func ExtractTags(path string) (*TrackMetadata, error) {
|
|||||||
|
|
||||||
// ExtractTagsFromReader reads metadata from an io.ReadSeeker.
|
// ExtractTagsFromReader reads metadata from an io.ReadSeeker.
|
||||||
func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) {
|
func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) {
|
||||||
|
// The container decides, so this is asked before tag.ReadFrom and
|
||||||
|
// not after its failure: a WAV's tags live in a RIFF chunk that
|
||||||
|
// dhowden/tag cannot see, and its fallback -- an ID3v1 trailer --
|
||||||
|
// would otherwise outrank them.
|
||||||
|
if meta, ok := wavTags(r); ok {
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
m, err := tag.ReadFrom(r)
|
m, err := tag.ReadFrom(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No tags found is not necessarily an error - return empty metadata
|
// No tags found is not necessarily an error - return empty metadata
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"yellowjacket/backend/riff"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wavTags reads the ID3v2 tag a WAV carries in its RIFF "id3 " chunk,
|
||||||
|
// which is where backend/tagwriter puts it and where dhowden/tag --
|
||||||
|
// having no RIFF reader at all -- cannot look. Without this a WAV
|
||||||
|
// scans as an untagged file however carefully it was tagged.
|
||||||
|
//
|
||||||
|
// ok is false when r is not a RIFF/WAVE container, and the read
|
||||||
|
// position is restored either way so the caller can carry on.
|
||||||
|
func wavTags(r io.ReadSeeker) (*TrackMetadata, bool) {
|
||||||
|
start, err := r.Seek(0, io.SeekCurrent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
id3Data, chunkErr := riff.ID3Chunk(r)
|
||||||
|
|
||||||
|
if _, err := r.Seek(start, io.SeekStart); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case chunkErr == nil:
|
||||||
|
return wavTagsFrom(id3Data), true
|
||||||
|
|
||||||
|
// Not ours to read: let the ordinary dispatch have the file.
|
||||||
|
case errors.Is(chunkErr, riff.ErrNotRIFF), errors.Is(chunkErr, riff.ErrNotWAVE):
|
||||||
|
return nil, false
|
||||||
|
|
||||||
|
// A RIFF container we cannot get a tag out of -- no chunk, an RF64
|
||||||
|
// file, a truncated header. That is a file with no readable tags,
|
||||||
|
// which is what the scanner's filename fallback is for.
|
||||||
|
default:
|
||||||
|
return &TrackMetadata{}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wavTagsFrom parses the bytes of a WAV's ID3v2 chunk.
|
||||||
|
func wavTagsFrom(id3Data []byte) *TrackMetadata {
|
||||||
|
meta, err := extractID3v2Lenient(bytes.NewReader(id3Data))
|
||||||
|
if err != nil {
|
||||||
|
// A tag holding no frames is not a damaged tag: writing every
|
||||||
|
// field back out empty leaves one, and warning about it would
|
||||||
|
// put a fault on a file that has none.
|
||||||
|
if errors.Is(err, ErrTagsUnreadable) {
|
||||||
|
return &TrackMetadata{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TrackMetadata{
|
||||||
|
TagReadWarning: fmt.Errorf("%w: %w", ErrTagsUnreadable, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractID3v2Lenient names MP3, being the recovery path for one.
|
||||||
|
meta.FileFormat = strings.ToUpper(strings.TrimPrefix(string(WAV), "."))
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// Package riff reads the chunk layout of a RIFF/WAVE container.
|
||||||
|
//
|
||||||
|
// It exists because both halves of WAV tagging need it and neither can
|
||||||
|
// import the other: backend/tagwriter writes a WAV's tags into a RIFF
|
||||||
|
// "id3 " chunk and already imports backend/metadata, which is what has
|
||||||
|
// to read them back out. backend/tagtotals is the precedent.
|
||||||
|
//
|
||||||
|
// The two readers here are deliberately different. Parse holds every
|
||||||
|
// chunk's data in memory, which is what rewriting a file needs; a WAV's
|
||||||
|
// audio *is* the "data" chunk, so doing that on the scan path would
|
||||||
|
// read every library file in full. ID3Chunk seeks over what it is not
|
||||||
|
// looking for instead. Both walk the same headers.
|
||||||
|
package riff
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors describing a container this package will not read.
|
||||||
|
var (
|
||||||
|
ErrRF64NotSupported = errors.New("RF64 files are not yet supported")
|
||||||
|
ErrNotRIFF = errors.New("not a RIFF file")
|
||||||
|
ErrNotWAVE = errors.New("not a WAVE file")
|
||||||
|
ErrNoID3Chunk = errors.New("no ID3 chunk in RIFF file")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chunk holds a single RIFF sub-chunk (ID + raw data).
|
||||||
|
type Chunk struct {
|
||||||
|
ID [4]byte
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsID3 reports whether id is that of an ID3v2 RIFF chunk. Both
|
||||||
|
// lowercase "id3 " and uppercase "ID3 " are accepted.
|
||||||
|
func IsID3(id [4]byte) bool {
|
||||||
|
return strings.ToLower(string(id[:3])) == "id3"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reads every RIFF sub-chunk from r, in order, starting at the
|
||||||
|
// reader's current position. It rejects RF64 files and non-WAVE
|
||||||
|
// containers with descriptive errors. The parser is lenient: it
|
||||||
|
// tolerates a missing final padding byte and ignores the declared
|
||||||
|
// RIFF size.
|
||||||
|
func Parse(r io.Reader) ([]Chunk, error) {
|
||||||
|
if err := readContainer(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []Chunk
|
||||||
|
|
||||||
|
for {
|
||||||
|
id, size, err := nextHeader(r)
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(r, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks = append(chunks, Chunk{ID: id, Data: data})
|
||||||
|
|
||||||
|
// Odd-length chunks have a padding byte. Lenient: if the
|
||||||
|
// read fails (e.g. EOF), just break rather than error.
|
||||||
|
if size%2 != 0 {
|
||||||
|
var pad [1]byte
|
||||||
|
|
||||||
|
if _, err := r.Read(pad[:]); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID3Chunk returns the payload of the ID3v2 chunk of the RIFF/WAVE
|
||||||
|
// container at the reader's current position, seeking over every other
|
||||||
|
// chunk rather than reading it. It returns ErrNoID3Chunk when the
|
||||||
|
// container carries no such chunk, and leaves the read position
|
||||||
|
// unspecified either way.
|
||||||
|
func ID3Chunk(r io.ReadSeeker) ([]byte, error) {
|
||||||
|
if err := readContainer(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
id, size, err := nextHeader(r)
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil, ErrNoID3Chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !IsID3(id) {
|
||||||
|
// Odd-length chunks carry a padding byte. Seeking past
|
||||||
|
// the end of the file is not an error; the next header
|
||||||
|
// read is what reports the end.
|
||||||
|
if _, err := r.Seek(int64(size)+int64(size%2), io.SeekCurrent); err != nil {
|
||||||
|
return nil, fmt.Errorf("skip chunk %q: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied rather than allocated up front: a truncated file is
|
||||||
|
// free to declare a chunk larger than the whole of itself.
|
||||||
|
var data bytes.Buffer
|
||||||
|
if _, err := io.CopyN(&data, r, int64(size)); err != nil {
|
||||||
|
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.Bytes(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readContainer consumes the 12-byte RIFF/WAVE header at the reader's
|
||||||
|
// current position.
|
||||||
|
func readContainer(r io.Reader) error {
|
||||||
|
var magic [4]byte
|
||||||
|
if _, err := io.ReadFull(r, magic[:]); err != nil {
|
||||||
|
return fmt.Errorf("read RIFF magic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(magic[:]) == "RF64" {
|
||||||
|
return ErrRF64NotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(magic[:]) != "RIFF" {
|
||||||
|
return fmt.Errorf("%w: got %q", ErrNotRIFF, magic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read (and discard) RIFF size — lenient, do not enforce.
|
||||||
|
var riffSize uint32
|
||||||
|
if err := binary.Read(r, binary.LittleEndian, &riffSize); err != nil {
|
||||||
|
return fmt.Errorf("read RIFF size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var form [4]byte
|
||||||
|
if _, err := io.ReadFull(r, form[:]); err != nil {
|
||||||
|
return fmt.Errorf("read WAVE form type: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(form[:]) != "WAVE" {
|
||||||
|
return fmt.Errorf("%w: got %q", ErrNotWAVE, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextHeader reads one sub-chunk header. It returns io.EOF once the
|
||||||
|
// chunks are exhausted, including for a header cut short.
|
||||||
|
func nextHeader(r io.Reader) ([4]byte, uint32, error) {
|
||||||
|
var id [4]byte
|
||||||
|
|
||||||
|
_, err := io.ReadFull(r, id[:])
|
||||||
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return id, 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return id, 0, fmt.Errorf("read chunk ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var size uint32
|
||||||
|
if err := binary.Read(r, binary.LittleEndian, &size); err != nil {
|
||||||
|
return id, 0, fmt.Errorf("read chunk size for %q: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, size, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package riff_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/riff"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chunk is one sub-chunk to put in a test container.
|
||||||
|
type chunk struct {
|
||||||
|
id string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRIFF assembles a container from magic, form type and chunks,
|
||||||
|
// padding odd-length chunks the way a writer must.
|
||||||
|
func buildRIFF(magic, form string, chunks []chunk) []byte {
|
||||||
|
var body bytes.Buffer
|
||||||
|
|
||||||
|
body.WriteString(form)
|
||||||
|
|
||||||
|
for _, c := range chunks {
|
||||||
|
body.WriteString(c.id)
|
||||||
|
_ = binary.Write(&body, binary.LittleEndian, uint32(len(c.data)))
|
||||||
|
body.Write(c.data)
|
||||||
|
|
||||||
|
if len(c.data)%2 != 0 {
|
||||||
|
body.WriteByte(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
out.WriteString(magic)
|
||||||
|
_ = binary.Write(&out, binary.LittleEndian, uint32(body.Len()))
|
||||||
|
out.Write(body.Bytes())
|
||||||
|
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestID3Chunk_FindsTheTagPastTheAudio(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
chunks []chunk
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "after an odd-length chunk",
|
||||||
|
chunks: []chunk{
|
||||||
|
{id: "fmt ", data: make([]byte, 16)},
|
||||||
|
{id: "LIST", data: []byte("INFOodd")},
|
||||||
|
{id: "data", data: make([]byte, 200)},
|
||||||
|
{id: "id3 ", data: []byte("ID3vTAG")},
|
||||||
|
},
|
||||||
|
want: "ID3vTAG",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The chunk ID is written both ways in the wild, and the
|
||||||
|
// writer accepts either, so the reader must too.
|
||||||
|
name: "uppercase ID3",
|
||||||
|
chunks: []chunk{
|
||||||
|
{id: "data", data: make([]byte, 8)},
|
||||||
|
{id: "ID3 ", data: []byte("upper")},
|
||||||
|
},
|
||||||
|
want: "upper",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first chunk",
|
||||||
|
chunks: []chunk{
|
||||||
|
{id: "id3 ", data: []byte("first")},
|
||||||
|
{id: "data", data: make([]byte, 8)},
|
||||||
|
},
|
||||||
|
want: "first",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := bytes.NewReader(buildRIFF("RIFF", "WAVE", tc.chunks))
|
||||||
|
|
||||||
|
got, err := riff.ID3Chunk(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ID3Chunk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(got) != tc.want {
|
||||||
|
t.Errorf("chunk data: got %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestID3Chunk_RejectsWhatItCannotRead(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bytes []byte
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no ID3 chunk",
|
||||||
|
bytes: buildRIFF("RIFF", "WAVE", []chunk{{id: "data", data: []byte{1, 2}}}),
|
||||||
|
want: riff.ErrNoID3Chunk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no chunks at all",
|
||||||
|
bytes: buildRIFF("RIFF", "WAVE", nil),
|
||||||
|
want: riff.ErrNoID3Chunk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not RIFF",
|
||||||
|
bytes: []byte("ID3\x03\x00\x00\x00\x00\x00\x00\x00\x00"),
|
||||||
|
want: riff.ErrNotRIFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not WAVE",
|
||||||
|
bytes: buildRIFF("RIFF", "AVI ", []chunk{{id: "id3 ", data: []byte("x")}}),
|
||||||
|
want: riff.ErrNotWAVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RF64",
|
||||||
|
bytes: buildRIFF("RF64", "WAVE", []chunk{{id: "id3 ", data: []byte("x")}}),
|
||||||
|
want: riff.ErrRF64NotSupported,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := riff.ID3Chunk(bytes.NewReader(tc.bytes))
|
||||||
|
if !errors.Is(err, tc.want) {
|
||||||
|
t.Errorf("ID3Chunk error: got %v, want %v", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file cut short mid-chunk is a file with no tag, not a reason to
|
||||||
|
// allocate the size it claims: the declared size is four bytes any
|
||||||
|
// truncation can leave saying 4 GB.
|
||||||
|
func TestID3Chunk_ToleratesATruncatedFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
full := buildRIFF("RIFF", "WAVE", []chunk{
|
||||||
|
{id: "data", data: make([]byte, 64)},
|
||||||
|
{id: "id3 ", data: []byte("tag")},
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cut inside the audio", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := riff.ID3Chunk(bytes.NewReader(full[:32]))
|
||||||
|
if !errors.Is(err, riff.ErrNoID3Chunk) {
|
||||||
|
t.Errorf("ID3Chunk error: got %v, want %v", err, riff.ErrNoID3Chunk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cut inside the tag", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if _, err := riff.ID3Chunk(bytes.NewReader(full[:len(full)-2])); err == nil {
|
||||||
|
t.Error("ID3Chunk: got nil error for a truncated tag chunk")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse is the writer's half and reads every chunk into memory, which
|
||||||
|
// is what preserving them needs.
|
||||||
|
func TestParse_ReadsEveryChunkInOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := buildRIFF("RIFF", "WAVE", []chunk{
|
||||||
|
{id: "fmt ", data: make([]byte, 16)},
|
||||||
|
{id: "LIST", data: []byte("INFOodd")},
|
||||||
|
{id: "id3 ", data: []byte("tag")},
|
||||||
|
})
|
||||||
|
|
||||||
|
chunks, err := riff.Parse(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"fmt ", "LIST", "id3 "}
|
||||||
|
if len(chunks) != len(want) {
|
||||||
|
t.Fatalf("chunk count: got %d, want %d", len(chunks), len(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, id := range want {
|
||||||
|
if got := string(chunks[i].ID[:]); got != id {
|
||||||
|
t.Errorf("chunk %d: got %q, want %q", i, got, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !riff.IsID3(chunks[2].ID) || string(chunks[2].Data) != "tag" {
|
||||||
|
t.Errorf("id3 chunk: got %q", chunks[2].Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The padding byte after an odd chunk is not part of its data.
|
||||||
|
if string(chunks[1].Data) != "INFOodd" {
|
||||||
|
t.Errorf("odd chunk data: got %q, want %q", chunks[1].Data, "INFOodd")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@ import (
|
|||||||
// indistinguishable from never having written one. So these assert the
|
// indistinguishable from never having written one. So these assert the
|
||||||
// round trip through the *reader the scan uses*, not the bytes.
|
// round trip through the *reader the scan uses*, not the bytes.
|
||||||
//
|
//
|
||||||
// WAV is the exception and it is not this change's: dhowden/tag has no
|
// WAV was the exception until #104 -- dhowden/tag has no RIFF reader,
|
||||||
// RIFF reader at all, so metadata.ExtractTags cannot see a WAV's ID3
|
// so metadata.ExtractTags could not see a WAV's ID3 chunk and this
|
||||||
// chunk -- which is why every other test here reads that chunk itself.
|
// case read the chunk itself, which is a test of the writer wearing
|
||||||
|
// the shape of a round trip. All four go through the scanner now.
|
||||||
func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
|
func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wav",
|
name: "wav",
|
||||||
read: readWavID3Tags,
|
read: viaScanner,
|
||||||
write: func(t *testing.T, dir string) string {
|
write: func(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
+11
-105
@@ -8,116 +8,22 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
id3v2 "github.com/bogem/id3v2/v2"
|
id3v2 "github.com/bogem/id3v2/v2"
|
||||||
|
|
||||||
"yellowjacket/backend/fileutil"
|
"yellowjacket/backend/fileutil"
|
||||||
|
"yellowjacket/backend/riff"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for WAV RIFF operations.
|
// errFileTooLargeForWAV is the one RIFF error that belongs to the
|
||||||
var (
|
// writer; reading rejects a container in backend/riff.
|
||||||
errRF64NotSupported = errors.New("RF64 files are not yet supported")
|
var errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")
|
||||||
errNotRIFF = errors.New("not a RIFF file")
|
|
||||||
errNotWAVE = errors.New("not a WAVE file")
|
|
||||||
errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")
|
|
||||||
)
|
|
||||||
|
|
||||||
// riffChunk holds a single RIFF sub-chunk (ID + raw data).
|
|
||||||
type riffChunk struct {
|
|
||||||
id [4]byte
|
|
||||||
data []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRIFF reads all RIFF sub-chunks from r. It rejects RF64 files
|
|
||||||
// and non-WAVE containers with descriptive errors. The parser is
|
|
||||||
// lenient on read: it tolerates missing padding bytes and ignores
|
|
||||||
// the declared RIFF size.
|
|
||||||
func parseRIFF(r io.ReadSeeker) ([]riffChunk, error) {
|
|
||||||
// Read 4-byte container magic.
|
|
||||||
var magic [4]byte
|
|
||||||
if _, err := io.ReadFull(r, magic[:]); err != nil {
|
|
||||||
return nil, fmt.Errorf("read RIFF magic: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(magic[:]) == "RF64" {
|
|
||||||
return nil, errRF64NotSupported
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(magic[:]) != "RIFF" {
|
|
||||||
return nil, fmt.Errorf("%w: got %q", errNotRIFF, magic)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read (and discard) RIFF size — lenient, do not enforce.
|
|
||||||
var riffSize uint32
|
|
||||||
if err := binary.Read(r, binary.LittleEndian, &riffSize); err != nil {
|
|
||||||
return nil, fmt.Errorf("read RIFF size: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read 4-byte form type.
|
|
||||||
var form [4]byte
|
|
||||||
if _, err := io.ReadFull(r, form[:]); err != nil {
|
|
||||||
return nil, fmt.Errorf("read WAVE form type: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(form[:]) != "WAVE" {
|
|
||||||
return nil, fmt.Errorf("%w: got %q", errNotWAVE, form)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read sub-chunks until EOF.
|
|
||||||
var chunks []riffChunk
|
|
||||||
|
|
||||||
for {
|
|
||||||
var chunkID [4]byte
|
|
||||||
|
|
||||||
_, err := io.ReadFull(r, chunkID[:])
|
|
||||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read chunk ID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var chunkSize uint32
|
|
||||||
if err := binary.Read(r, binary.LittleEndian, &chunkSize); err != nil {
|
|
||||||
return nil, fmt.Errorf("read chunk size for %q: %w", chunkID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := make([]byte, chunkSize)
|
|
||||||
if _, err := io.ReadFull(r, data); err != nil {
|
|
||||||
return nil, fmt.Errorf("read chunk data for %q: %w", chunkID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks = append(chunks, riffChunk{id: chunkID, data: data})
|
|
||||||
|
|
||||||
// Odd-length chunks have a padding byte. Lenient: if the
|
|
||||||
// read fails (e.g. EOF), just break rather than error.
|
|
||||||
if chunkSize%2 != 0 {
|
|
||||||
var pad [1]byte
|
|
||||||
|
|
||||||
if _, err := r.Read(pad[:]); err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isID3ChunkID returns true if id represents an ID3v2 RIFF chunk.
|
|
||||||
// Both lowercase "id3 " and uppercase "ID3 " are accepted.
|
|
||||||
func isID3ChunkID(id [4]byte) bool {
|
|
||||||
s := strings.ToLower(string(id[:3]))
|
|
||||||
|
|
||||||
return s == "id3"
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeRIFF writes a complete RIFF/WAVE container to w, preserving
|
// writeRIFF writes a complete RIFF/WAVE container to w, preserving
|
||||||
// the given chunks in order and appending the id3Data as the final
|
// the given chunks in order and appending the id3Data as the final
|
||||||
// "id3 " chunk. Returns errFileTooLargeForWAV if the result would
|
// "id3 " chunk. Returns errFileTooLargeForWAV if the result would
|
||||||
// exceed the 4 GB RIFF limit.
|
// exceed the 4 GB RIFF limit.
|
||||||
func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error {
|
func writeRIFF(w io.Writer, chunks []riff.Chunk, id3Data []byte) error {
|
||||||
// Calculate total RIFF payload size:
|
// Calculate total RIFF payload size:
|
||||||
// 4 bytes (WAVE form type)
|
// 4 bytes (WAVE form type)
|
||||||
// + for each preserved chunk: 8 (header) + len(data) + padding
|
// + for each preserved chunk: 8 (header) + len(data) + padding
|
||||||
@@ -125,7 +31,7 @@ func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error {
|
|||||||
riffPayload := uint64(4)
|
riffPayload := uint64(4)
|
||||||
|
|
||||||
for _, c := range chunks {
|
for _, c := range chunks {
|
||||||
sz := uint64(len(c.data))
|
sz := uint64(len(c.Data))
|
||||||
riffPayload += 8 + sz
|
riffPayload += 8 + sz
|
||||||
|
|
||||||
if sz%2 != 0 {
|
if sz%2 != 0 {
|
||||||
@@ -162,7 +68,7 @@ func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error {
|
|||||||
|
|
||||||
// Write each preserved chunk.
|
// Write each preserved chunk.
|
||||||
for _, c := range chunks {
|
for _, c := range chunks {
|
||||||
if err := writeChunk(w, c.id, c.data); err != nil {
|
if err := writeChunk(w, c.ID, c.Data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +131,7 @@ func writeWavTags(
|
|||||||
return fmt.Errorf("open wav for reading: %w", err)
|
return fmt.Errorf("open wav for reading: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
allChunks, err := parseRIFF(f)
|
allChunks, err := riff.Parse(f)
|
||||||
|
|
||||||
// Close immediately — we need the handle released before
|
// Close immediately — we need the handle released before
|
||||||
// AtomicWrite creates the replacement file.
|
// AtomicWrite creates the replacement file.
|
||||||
@@ -237,13 +143,13 @@ func writeWavTags(
|
|||||||
|
|
||||||
// Separate preserved chunks from existing ID3 data.
|
// Separate preserved chunks from existing ID3 data.
|
||||||
var (
|
var (
|
||||||
preserved []riffChunk
|
preserved []riff.Chunk
|
||||||
existingID3 []byte
|
existingID3 []byte
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, c := range allChunks {
|
for _, c := range allChunks {
|
||||||
if isID3ChunkID(c.id) {
|
if riff.IsID3(c.ID) {
|
||||||
existingID3 = c.data
|
existingID3 = c.Data
|
||||||
} else {
|
} else {
|
||||||
preserved = append(preserved, c)
|
preserved = append(preserved, c)
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-17
@@ -12,6 +12,7 @@ import (
|
|||||||
id3v2 "github.com/bogem/id3v2/v2"
|
id3v2 "github.com/bogem/id3v2/v2"
|
||||||
|
|
||||||
"yellowjacket/backend/metadata"
|
"yellowjacket/backend/metadata"
|
||||||
|
"yellowjacket/backend/riff"
|
||||||
)
|
)
|
||||||
|
|
||||||
// createTestWAV builds a minimal valid WAV file with an optional
|
// createTestWAV builds a minimal valid WAV file with an optional
|
||||||
@@ -270,6 +271,88 @@ func TestWriteWavTags_PartialUpdate(t *testing.T) {
|
|||||||
assertStrField(t, "Composer", meta.Composer, "Original Composer")
|
assertStrField(t, "Composer", meta.Composer, "Original Composer")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The writer has always been correct and the reader could not see it:
|
||||||
|
// a WAV tagged by this app scanned as an untagged file, so editing
|
||||||
|
// tags, autotagging a folder or importing a WAV download all appeared
|
||||||
|
// to work and changed nothing the library could show (#104). So this
|
||||||
|
// asserts the write through metadata.ExtractTags -- the reader the
|
||||||
|
// scan uses -- rather than through the id3 chunk.
|
||||||
|
func TestWriteWavTags_ReadBackByTheScanner(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := createTestWAV(t, dir, "scanner.wav", nil)
|
||||||
|
art := tinyJPEG(t)
|
||||||
|
|
||||||
|
changes := TagChanges{
|
||||||
|
FieldTitle: "Some Song",
|
||||||
|
FieldArtist: "Some Artist",
|
||||||
|
FieldAlbum: "Some Album",
|
||||||
|
FieldAlbumArtist: "Some Album Artist",
|
||||||
|
FieldGenre: "Rock",
|
||||||
|
FieldYear: 2024,
|
||||||
|
FieldTrackNumber: 3,
|
||||||
|
FieldComposer: "Some Composer",
|
||||||
|
FieldCoverArt: art,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeWavTags(testLogger(), path, changes); err != nil {
|
||||||
|
t.Fatalf("writeWavTags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := metadata.ExtractTags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractTags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.TagReadWarning != nil {
|
||||||
|
t.Errorf("TagReadWarning: %v", meta.TagReadWarning)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertStrField(t, "Title", meta.Title, "Some Song")
|
||||||
|
assertStrField(t, "Artist", meta.Artist, "Some Artist")
|
||||||
|
assertStrField(t, "Album", meta.Album, "Some Album")
|
||||||
|
assertStrField(t, "AlbumArtist", meta.AlbumArtist, "Some Album Artist")
|
||||||
|
assertStrField(t, "Genre", meta.Genre, "Rock")
|
||||||
|
assertStrField(t, "Composer", meta.Composer, "Some Composer")
|
||||||
|
assertStrField(t, "FileFormat", meta.FileFormat, "WAV")
|
||||||
|
assertIntField(t, "Year", meta.Year, 2024)
|
||||||
|
assertIntField(t, "TrackNumber", meta.TrackNumber, 3)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(meta.TagFormat, "ID3v2") {
|
||||||
|
t.Errorf("TagFormat: got %q, want an ID3v2 version", meta.TagFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.Picture == nil {
|
||||||
|
t.Fatal("expected cover art, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(meta.Picture.Data, art) {
|
||||||
|
t.Errorf("picture data mismatch: got %d bytes, want %d",
|
||||||
|
len(meta.Picture.Data), len(art))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An untagged WAV is a file with no tags, not a file with a problem:
|
||||||
|
// the scanner falls back to the filename and must not be handed a
|
||||||
|
// warning to surface about it.
|
||||||
|
func TestUntaggedWav_ReadsAsEmptyWithoutAWarning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := createTestWAV(t, t.TempDir(), "bare.wav", nil)
|
||||||
|
|
||||||
|
meta, err := metadata.ExtractTags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractTags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.TagReadWarning != nil {
|
||||||
|
t.Errorf("TagReadWarning: %v", meta.TagReadWarning)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertStrField(t, "Title", meta.Title, "")
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteWavTags_ChunkPreservation(t *testing.T) {
|
func TestWriteWavTags_ChunkPreservation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -282,7 +365,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
|
|||||||
t.Fatalf("open original: %v", err)
|
t.Fatalf("open original: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
origChunks, err := parseRIFF(origFile)
|
origChunks, err := riff.Parse(origFile)
|
||||||
_ = origFile.Close()
|
_ = origFile.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -292,7 +375,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
|
|||||||
// Record original chunk data by ID string.
|
// Record original chunk data by ID string.
|
||||||
origData := map[string][]byte{}
|
origData := map[string][]byte{}
|
||||||
for _, c := range origChunks {
|
for _, c := range origChunks {
|
||||||
origData[string(c.id[:])] = c.data
|
origData[string(c.ID[:])] = c.Data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write a tag to trigger RIFF rewrite.
|
// Write a tag to trigger RIFF rewrite.
|
||||||
@@ -309,7 +392,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
|
|||||||
t.Fatalf("open after write: %v", err)
|
t.Fatalf("open after write: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newChunks, err := parseRIFF(newFile)
|
newChunks, err := riff.Parse(newFile)
|
||||||
_ = newFile.Close()
|
_ = newFile.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -320,7 +403,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
|
|||||||
origNonID3 := 0
|
origNonID3 := 0
|
||||||
|
|
||||||
for _, c := range origChunks {
|
for _, c := range origChunks {
|
||||||
if !isID3ChunkID(c.id) {
|
if !riff.IsID3(c.ID) {
|
||||||
origNonID3++
|
origNonID3++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +411,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
|
|||||||
newNonID3 := 0
|
newNonID3 := 0
|
||||||
|
|
||||||
for _, c := range newChunks {
|
for _, c := range newChunks {
|
||||||
if !isID3ChunkID(c.id) {
|
if !riff.IsID3(c.ID) {
|
||||||
newNonID3++
|
newNonID3++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,17 +442,17 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
|
|||||||
// in chunks and its data matches want byte-for-byte.
|
// in chunks and its data matches want byte-for-byte.
|
||||||
func checkChunkPreserved(
|
func checkChunkPreserved(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
chunks []riffChunk,
|
chunks []riff.Chunk,
|
||||||
idStr string,
|
idStr string,
|
||||||
want []byte,
|
want []byte,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
for _, c := range chunks {
|
for _, c := range chunks {
|
||||||
if string(c.id[:]) == idStr {
|
if string(c.ID[:]) == idStr {
|
||||||
if !bytes.Equal(c.data, want) {
|
if !bytes.Equal(c.Data, want) {
|
||||||
t.Errorf("chunk %q data changed: got %d bytes, want %d",
|
t.Errorf("chunk %q data changed: got %d bytes, want %d",
|
||||||
idStr, len(c.data), len(want))
|
idStr, len(c.Data), len(want))
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -430,7 +513,7 @@ func TestWriteWavTags_RejectsRF64(t *testing.T) {
|
|||||||
buf.WriteString("WAVE")
|
buf.WriteString("WAVE")
|
||||||
|
|
||||||
// Minimal ds64 chunk (required for RF64 but we just need
|
// Minimal ds64 chunk (required for RF64 but we just need
|
||||||
// enough bytes for parseRIFF to hit the RF64 rejection).
|
// enough bytes for riff.Parse to hit the RF64 rejection).
|
||||||
buf.WriteString("ds64")
|
buf.WriteString("ds64")
|
||||||
_ = binary.Write(&buf, binary.LittleEndian, uint32(28)) //nolint:mnd
|
_ = binary.Write(&buf, binary.LittleEndian, uint32(28)) //nolint:mnd
|
||||||
buf.Write(make([]byte, 28)) //nolint:mnd
|
buf.Write(make([]byte, 28)) //nolint:mnd
|
||||||
@@ -454,9 +537,12 @@ func TestWriteWavTags_RejectsRF64(t *testing.T) {
|
|||||||
|
|
||||||
// readWavID3Tags extracts ID3v2 metadata from a WAV file by parsing
|
// readWavID3Tags extracts ID3v2 metadata from a WAV file by parsing
|
||||||
// the RIFF structure and reading the id3 chunk with bogem/id3v2.
|
// the RIFF structure and reading the id3 chunk with bogem/id3v2.
|
||||||
// dhowden/tag's ReadFrom does not support WAV files, and its
|
//
|
||||||
// ReadID3v2Tags fails on empty tags (after clearing all frames).
|
// metadata.ExtractTags reads a WAV since #104 and is what the round
|
||||||
// Using bogem/id3v2.ParseReader handles all cases correctly.
|
// trips assert through. This stays for the two cases that are about
|
||||||
|
// the bytes rather than about the scan: a tag with every frame
|
||||||
|
// cleared, which no reader reports as anything, and the chunk
|
||||||
|
// preservation test, which is already parsing the container itself.
|
||||||
func readWavID3Tags(
|
func readWavID3Tags(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
path string,
|
path string,
|
||||||
@@ -470,17 +556,17 @@ func readWavID3Tags(
|
|||||||
|
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
chunks, err := parseRIFF(f)
|
chunks, err := riff.Parse(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parseRIFF: %v", err)
|
t.Fatalf("riff.Parse: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the id3 chunk.
|
// Find the id3 chunk.
|
||||||
var id3Data []byte
|
var id3Data []byte
|
||||||
|
|
||||||
for _, c := range chunks {
|
for _, c := range chunks {
|
||||||
if isID3ChunkID(c.id) {
|
if riff.IsID3(c.ID) {
|
||||||
id3Data = c.data
|
id3Data = c.Data
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from '../support/fixtures.js';
|
import { test, expect, callBinding } from '../support/fixtures.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The touch gestures against the real app (plan 019, #63; long-press
|
* The touch gestures against the real app (plan 019, #63; long-press
|
||||||
@@ -192,3 +192,120 @@ test.describe('a hold anywhere else still opens the menu', () => {
|
|||||||
expect((await panel(app, 'cover-grid'))?.items).toBeGreaterThan(0);
|
expect((await panel(app, 'cover-grid'))?.items).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swipe right on a track row to queue it (plan 019 phase 2, #63).
|
||||||
|
*
|
||||||
|
* The component tier has the rule this obeys — one row is a position,
|
||||||
|
* several are a choice — against a queue that is a fake. What is only
|
||||||
|
* true here is that the gesture reaches the *real* queue: `AddTracks`
|
||||||
|
* is a Go method, the queue is persisted, and "the row was added"
|
||||||
|
* is a question only the backend can answer.
|
||||||
|
*
|
||||||
|
* **It is Chromium-only, and that is a property of the browser rather
|
||||||
|
* than a gap.** The gesture runs on touch events, because Chrome 113's
|
||||||
|
* WebView cancels the pointer stream ~16px into any drag whatever
|
||||||
|
* `touch-action` says. Desktop WebKit implements no `TouchEvent`
|
||||||
|
* constructor at all — touch events are a mobile-Safari surface — so
|
||||||
|
* the events this needs cannot be built there. Skipping loudly is
|
||||||
|
* better than a spec that quietly asserts nothing on half the matrix,
|
||||||
|
* which is what `layout-overflow.spec.ts` and `back-navigation.spec.ts`
|
||||||
|
* were each doing when they were green on a broken build.
|
||||||
|
*/
|
||||||
|
test.describe('a swipe right on a track row queues it', () => {
|
||||||
|
test.beforeEach(async ({ app, browserName }) => {
|
||||||
|
test.skip(
|
||||||
|
browserName !== 'chromium',
|
||||||
|
'desktop WebKit has no TouchEvent constructor to build the gesture from',
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.setViewportSize(PHONE);
|
||||||
|
await app.getByTestId('tab-tracks').click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'tracks',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ app }) => {
|
||||||
|
await app.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag the first row sideways by a fraction of its own width and
|
||||||
|
* lift. `fraction` is against the row, because the commit threshold
|
||||||
|
* is — a number of pixels here would be a second declaration of it,
|
||||||
|
* right on one viewport and wrong on the next.
|
||||||
|
*/
|
||||||
|
const swipeFirstRow = (page: Page, fraction: number) =>
|
||||||
|
page.evaluate((f) => {
|
||||||
|
const row = document
|
||||||
|
.querySelector('track-list')
|
||||||
|
?.shadowRoot?.querySelector('.track-row');
|
||||||
|
|
||||||
|
if (!row) throw new Error('no track row to swipe');
|
||||||
|
|
||||||
|
const box = row.getBoundingClientRect();
|
||||||
|
const y = box.top + box.height / 2;
|
||||||
|
const at = (x: number) =>
|
||||||
|
new Touch({
|
||||||
|
identifier: 1,
|
||||||
|
target: row,
|
||||||
|
clientX: box.left + x,
|
||||||
|
clientY: y,
|
||||||
|
});
|
||||||
|
const send = (type: string, points: Touch[]) =>
|
||||||
|
row.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(box.width * f * step)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
send('touchend', []);
|
||||||
|
}, fraction);
|
||||||
|
|
||||||
|
/** How many tracks the backend says are in the queue. */
|
||||||
|
const queueLength = async (page: Page) => {
|
||||||
|
const state = await callBinding<{ tracks: unknown[] }>(
|
||||||
|
page,
|
||||||
|
'queue.Queue.GetState',
|
||||||
|
);
|
||||||
|
|
||||||
|
return state.tracks?.length ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('adds exactly one track to the real queue', async ({ app }) => {
|
||||||
|
const before = await queueLength(app);
|
||||||
|
|
||||||
|
await swipeFirstRow(app, 0.6);
|
||||||
|
|
||||||
|
await expect.poll(() => queueLength(app)).toBe(before + 1);
|
||||||
|
|
||||||
|
// Queued, not played: a swipe is not a tap, and the difference is
|
||||||
|
// what is on screen afterwards.
|
||||||
|
expect(
|
||||||
|
await app.getByTestId('main-content').getAttribute('data-active-view'),
|
||||||
|
).toBe('tracks');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does nothing when the finger did not get far enough', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
const before = await queueLength(app);
|
||||||
|
|
||||||
|
await swipeFirstRow(app, 0.1);
|
||||||
|
await app.waitForTimeout(400);
|
||||||
|
|
||||||
|
expect(await queueLength(app)).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
} from '@utils/context-menu-controller.js';
|
} from '@utils/context-menu-controller.js';
|
||||||
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
|
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
|
||||||
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
|
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 { FavoritesController } from '@store/controllers/favorites-controller';
|
||||||
import { notificationStore } from '@store/notification-store';
|
import { notificationStore } from '@store/notification-store';
|
||||||
import { describeError } from '@utils/describe-error';
|
import { describeError } from '@utils/describe-error';
|
||||||
@@ -71,11 +75,14 @@ import {
|
|||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
} from '@utils/explore-link';
|
} from '@utils/explore-link';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import { srOnly } from '../../styles/sr-only.css';
|
||||||
import { backButton } from '../../styles/back-button.css';
|
import { backButton } from '../../styles/back-button.css';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
import {
|
import {
|
||||||
|
ICON_PLAY,
|
||||||
ICON_PLAYLIST,
|
ICON_PLAYLIST,
|
||||||
ICON_QUEUE,
|
ICON_QUEUE,
|
||||||
|
ICON_REMOVE,
|
||||||
} from '@utils/icon-language';
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
/** One playlist row: the track and its position in the *playlist*,
|
/** 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 });
|
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(
|
private handleTrackContextMenu(
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
trackIndex: number,
|
trackIndex: number,
|
||||||
@@ -955,9 +1083,11 @@ export class PlaylistDetails
|
|||||||
|
|
||||||
static override styles = [
|
static override styles = [
|
||||||
designTokens,
|
designTokens,
|
||||||
|
srOnly,
|
||||||
backButton,
|
backButton,
|
||||||
contextMenuStyles,
|
contextMenuStyles,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
|
swipeRevealStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1136,6 +1266,9 @@ export class PlaylistDetails
|
|||||||
.track-item {
|
.track-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
/* The swipe reveal is absolute inside the row. */
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-header {
|
.track-header {
|
||||||
@@ -1433,6 +1566,9 @@ export class PlaylistDetails
|
|||||||
<div class="header-cell col-album">Album</div>
|
<div class="header-cell col-album">Album</div>
|
||||||
<div class="header-cell col-duration">Duration</div>
|
<div class="header-cell col-duration">Duration</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sr-only" role="status" aria-live="polite">
|
||||||
|
${this.swipe.announcement}
|
||||||
|
</div>
|
||||||
<lit-virtualizer
|
<lit-virtualizer
|
||||||
class="track-scroller"
|
class="track-scroller"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
@@ -1442,7 +1578,13 @@ export class PlaylistDetails
|
|||||||
.renderItem=${this.renderRow}
|
.renderItem=${this.renderRow}
|
||||||
.keyFunction=${this.rowKey}
|
.keyFunction=${this.rowKey}
|
||||||
.layout=${this.flowLayout}
|
.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>
|
></lit-virtualizer>
|
||||||
|
${this.renderSelectionBar()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1464,6 +1606,7 @@ export class PlaylistDetails
|
|||||||
active ? 'active' : '',
|
active ? 'active' : '',
|
||||||
selected ? 'selected' : '',
|
selected ? 'selected' : '',
|
||||||
isPhantom ? 'phantom' : '',
|
isPhantom ? 'phantom' : '',
|
||||||
|
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
@@ -1474,6 +1617,7 @@ export class PlaylistDetails
|
|||||||
role="option"
|
role="option"
|
||||||
aria-selected=${selected}
|
aria-selected=${selected}
|
||||||
data-index=${trackIndex}
|
data-index=${trackIndex}
|
||||||
|
data-swipe
|
||||||
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
|
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
|
||||||
@keydown=${(e: KeyboardEvent) =>
|
@keydown=${(e: KeyboardEvent) =>
|
||||||
this.onRowKeydown(e, trackIndex)}
|
this.onRowKeydown(e, trackIndex)}
|
||||||
@@ -1520,6 +1664,7 @@ export class PlaylistDetails
|
|||||||
? nothing
|
? nothing
|
||||||
: this.onTrackDragEnd}
|
: this.onTrackDragEnd}
|
||||||
>
|
>
|
||||||
|
${this.swipe.renderReveal(trackIndex)}
|
||||||
${isPhantom
|
${isPhantom
|
||||||
? html`<div
|
? html`<div
|
||||||
class="phantom-row"
|
class="phantom-row"
|
||||||
|
|||||||
@@ -64,9 +64,14 @@ import {
|
|||||||
} from '@utils/explore-link';
|
} from '@utils/explore-link';
|
||||||
import {
|
import {
|
||||||
ICON_NEW,
|
ICON_NEW,
|
||||||
|
ICON_PLAY,
|
||||||
ICON_PLAYLIST,
|
ICON_PLAYLIST,
|
||||||
ICON_QUEUE,
|
ICON_QUEUE,
|
||||||
|
ICON_REMOVE,
|
||||||
} from '@utils/icon-language';
|
} 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. */
|
/** Above this many tracks, clearing the queue asks first. */
|
||||||
const CLEAR_CONFIRM_THRESHOLD = 20;
|
const CLEAR_CONFIRM_THRESHOLD = 20;
|
||||||
|
|
||||||
@@ -844,6 +849,8 @@ export class QueuePanel
|
|||||||
virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
|
virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
|
||||||
virtEl.addEventListener('dragend', this.onTrackDragEnd);
|
virtEl.addEventListener('dragend', this.onTrackDragEnd);
|
||||||
virtEl.addEventListener('keydown', this.onDelegatedKeydown);
|
virtEl.addEventListener('keydown', this.onDelegatedKeydown);
|
||||||
|
virtEl.addEventListener('yj-tap', this.onRowTap);
|
||||||
|
virtEl.addEventListener('yj-long-press', this.onRowLongPress);
|
||||||
this.delegationAttached = true;
|
this.delegationAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,6 +969,8 @@ export class QueuePanel
|
|||||||
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
|
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
|
||||||
virtEl.removeEventListener('dragend', this.onTrackDragEnd);
|
virtEl.removeEventListener('dragend', this.onTrackDragEnd);
|
||||||
virtEl.removeEventListener('keydown', this.onDelegatedKeydown);
|
virtEl.removeEventListener('keydown', this.onDelegatedKeydown);
|
||||||
|
virtEl.removeEventListener('yj-tap', this.onRowTap);
|
||||||
|
virtEl.removeEventListener('yj-long-press', this.onRowLongPress);
|
||||||
}
|
}
|
||||||
this.delegationAttached = false;
|
this.delegationAttached = false;
|
||||||
}
|
}
|
||||||
@@ -1247,6 +1256,92 @@ export class QueuePanel
|
|||||||
this.queue.playAtIndex(index);
|
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(
|
private handleTrackContextMenu(
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -2157,6 +2252,7 @@ export class QueuePanel
|
|||||||
></lit-virtualizer>
|
></lit-virtualizer>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
${this.renderSelectionBar()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<menu-surface
|
<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,
|
* region: the number changes under the user's finger as they tap rows,
|
||||||
* and nothing else on screen announces it.
|
* 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
|
* **It renders nothing at zero.** The mode ends when the last row is
|
||||||
* deselected — `SelectionController.toggleInMode` is where that is
|
* deselected — `SelectionController.toggleInMode` is where that is
|
||||||
* decided — so a bar with a count of none is a state this should never
|
* 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() {
|
override render() {
|
||||||
if (this.count <= 0) return nothing;
|
if (this.count <= 0) return nothing;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import {
|
|||||||
} from '@utils/context-menu-controller.js';
|
} from '@utils/context-menu-controller.js';
|
||||||
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
|
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
|
||||||
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
|
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 { FavoritesController } from '@store/controllers/favorites-controller';
|
||||||
import {
|
import {
|
||||||
setDragPayload,
|
setDragPayload,
|
||||||
@@ -63,8 +67,11 @@ import {
|
|||||||
import '@components/smart-playlist-editor/smart-playlist-editor.js';
|
import '@components/smart-playlist-editor/smart-playlist-editor.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { backButton } from '../../styles/back-button.css';
|
import { backButton } from '../../styles/back-button.css';
|
||||||
|
import { srOnly } from '../../styles/sr-only.css';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
import {
|
import {
|
||||||
|
ICON_PLAY,
|
||||||
|
ICON_PLAY_NEXT,
|
||||||
ICON_PLAYLIST,
|
ICON_PLAYLIST,
|
||||||
ICON_QUEUE,
|
ICON_QUEUE,
|
||||||
ICON_SMART_PLAYLIST,
|
ICON_SMART_PLAYLIST,
|
||||||
@@ -243,9 +250,11 @@ export class SmartPlaylistDetails
|
|||||||
|
|
||||||
static override styles = [
|
static override styles = [
|
||||||
designTokens,
|
designTokens,
|
||||||
|
srOnly,
|
||||||
backButton,
|
backButton,
|
||||||
contextMenuStyles,
|
contextMenuStyles,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
|
swipeRevealStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -444,6 +453,9 @@ export class SmartPlaylistDetails
|
|||||||
.track-item {
|
.track-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
/* The swipe reveal is absolute inside the row. */
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-header {
|
.track-header {
|
||||||
@@ -920,6 +932,110 @@ export class SmartPlaylistDetails
|
|||||||
// Context menu actions
|
// 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) {
|
private onContextMenuAction(action: string) {
|
||||||
const filePaths = this.getSelectedFilePaths();
|
const filePaths = this.getSelectedFilePaths();
|
||||||
|
|
||||||
@@ -1334,6 +1450,9 @@ export class SmartPlaylistDetails
|
|||||||
<div class="header-cell col-album">Album</div>
|
<div class="header-cell col-album">Album</div>
|
||||||
<div class="header-cell col-duration">Duration</div>
|
<div class="header-cell col-duration">Duration</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sr-only" role="status" aria-live="polite">
|
||||||
|
${this.swipe.announcement}
|
||||||
|
</div>
|
||||||
<lit-virtualizer
|
<lit-virtualizer
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Smart playlist tracks"
|
aria-label="Smart playlist tracks"
|
||||||
@@ -1342,7 +1461,13 @@ export class SmartPlaylistDetails
|
|||||||
.renderItem=${this.renderRow}
|
.renderItem=${this.renderRow}
|
||||||
.keyFunction=${this.rowKey}
|
.keyFunction=${this.rowKey}
|
||||||
.layout=${this.flowLayout}
|
.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>
|
></lit-virtualizer>
|
||||||
|
${this.renderSelectionBar()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,6 +1489,7 @@ export class SmartPlaylistDetails
|
|||||||
active ? 'active' : '',
|
active ? 'active' : '',
|
||||||
selected ? 'selected' : '',
|
selected ? 'selected' : '',
|
||||||
isPhantom ? 'phantom' : '',
|
isPhantom ? 'phantom' : '',
|
||||||
|
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
@@ -1374,6 +1500,7 @@ export class SmartPlaylistDetails
|
|||||||
role="option"
|
role="option"
|
||||||
aria-selected=${selected}
|
aria-selected=${selected}
|
||||||
data-index=${trackIndex}
|
data-index=${trackIndex}
|
||||||
|
data-swipe
|
||||||
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
|
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
|
||||||
@keydown=${(e: KeyboardEvent) =>
|
@keydown=${(e: KeyboardEvent) =>
|
||||||
this.onRowKeydown(e, trackIndex)}
|
this.onRowKeydown(e, trackIndex)}
|
||||||
@@ -1408,6 +1535,7 @@ export class SmartPlaylistDetails
|
|||||||
? nothing
|
? nothing
|
||||||
: this.onTrackDragEnd}
|
: this.onTrackDragEnd}
|
||||||
>
|
>
|
||||||
|
${this.swipe.renderReveal(trackIndex)}
|
||||||
${isPhantom
|
${isPhantom
|
||||||
? html`<div class="phantom-row">
|
? html`<div class="phantom-row">
|
||||||
<wa-icon
|
<wa-icon
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { SelectionController } from '@utils/selection-controller';
|
import { SelectionController } from '@utils/selection-controller';
|
||||||
import type { SelectionHost } from '@utils/selection-controller';
|
import type { SelectionHost } from '@utils/selection-controller';
|
||||||
import type { GestureEvent } 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 '@components/selection-bar/selection-bar';
|
||||||
import type { SelectionAction } from '@components/selection-bar/selection-bar';
|
import type { SelectionAction } from '@components/selection-bar/selection-bar';
|
||||||
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
||||||
@@ -282,6 +283,29 @@ export class TrackList
|
|||||||
* path into it at all (H-5). */
|
* path into it at all (H-5). */
|
||||||
@state() private focusedIndex = 0;
|
@state() private focusedIndex = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
if (!hit) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: hit.index,
|
||||||
|
filePaths: this.swipeTargetKeys(hit.track.FilePath),
|
||||||
|
label: hit.track.TrackName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
repaint: () => this.virtualizer?.requestUpdate(),
|
||||||
|
});
|
||||||
|
|
||||||
private handleSelectAll = (): void => {
|
private handleSelectAll = (): void => {
|
||||||
this.selection.selectAll();
|
this.selection.selectAll();
|
||||||
};
|
};
|
||||||
@@ -1016,7 +1040,7 @@ export class TrackList
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, css`
|
static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, swipeRevealStyles, css`
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1213,6 +1237,7 @@ export class TrackList
|
|||||||
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1311,6 +1336,9 @@ export class TrackList
|
|||||||
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
|
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
|
||||||
virt.removeEventListener('yj-tap', this.onRowTap);
|
virt.removeEventListener('yj-tap', this.onRowTap);
|
||||||
virt.removeEventListener('yj-long-press', this.onRowLongPress);
|
virt.removeEventListener('yj-long-press', this.onRowLongPress);
|
||||||
|
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('dragstart', this.onDelegatedDragStart);
|
||||||
virt.removeEventListener('dragend', this.onTrackDragEnd);
|
virt.removeEventListener('dragend', this.onTrackDragEnd);
|
||||||
}
|
}
|
||||||
@@ -1457,6 +1485,9 @@ export class TrackList
|
|||||||
// through the same path a real click takes (plan 019).
|
// through the same path a real click takes (plan 019).
|
||||||
virt.addEventListener('yj-tap', this.onRowTap);
|
virt.addEventListener('yj-tap', this.onRowTap);
|
||||||
virt.addEventListener('yj-long-press', this.onRowLongPress);
|
virt.addEventListener('yj-long-press', this.onRowLongPress);
|
||||||
|
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;
|
this.delegationAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1776,6 +1807,30 @@ export class TrackList
|
|||||||
this.virtualizer?.requestUpdate();
|
this.virtualizer?.requestUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a swipe on this row would queue.
|
||||||
|
*
|
||||||
|
* The same rule the context menu answers with, and it has to be:
|
||||||
|
* **one row is a position, several rows are an explicit choice.**
|
||||||
|
* A finger that swipes a row which is part of a selection of forty
|
||||||
|
* has not un-made that selection, and queueing the one row it
|
||||||
|
* touched would quietly contradict the bar above saying forty are
|
||||||
|
* selected. A swipe on a row *outside* the selection is a statement
|
||||||
|
* about that row, exactly as a right-click on one is -- and unlike
|
||||||
|
* a right-click it does not move the selection, because a swipe is
|
||||||
|
* not a way of selecting anything.
|
||||||
|
*/
|
||||||
|
private swipeTargetKeys(filePath: string): string[] {
|
||||||
|
if (
|
||||||
|
this.selection.selectionCount > 1 &&
|
||||||
|
this.selection.isSelected(filePath)
|
||||||
|
) {
|
||||||
|
return this.selection.getSelectedKeysOrdered();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [filePath];
|
||||||
|
}
|
||||||
|
|
||||||
private onDelegatedDragStart = (e: DragEvent) => {
|
private onDelegatedDragStart = (e: DragEvent) => {
|
||||||
const hit = this.resolveTrackFromEvent(e);
|
const hit = this.resolveTrackFromEvent(e);
|
||||||
|
|
||||||
@@ -2197,6 +2252,7 @@ export class TrackList
|
|||||||
'track-row': true,
|
'track-row': true,
|
||||||
active,
|
active,
|
||||||
selected,
|
selected,
|
||||||
|
swiping: this.swipe.isSwiping(index),
|
||||||
})}
|
})}
|
||||||
role="row"
|
role="row"
|
||||||
aria-rowindex=${index + 1}
|
aria-rowindex=${index + 1}
|
||||||
@@ -2206,8 +2262,10 @@ export class TrackList
|
|||||||
draggable="true"
|
draggable="true"
|
||||||
data-index=${index}
|
data-index=${index}
|
||||||
data-testid="track-row"
|
data-testid="track-row"
|
||||||
|
data-swipe
|
||||||
data-file-path=${track.FilePath}
|
data-file-path=${track.FilePath}
|
||||||
>
|
>
|
||||||
|
${this.swipe.renderReveal(index)}
|
||||||
<div
|
<div
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
class=${classMap({
|
class=${classMap({
|
||||||
@@ -2354,6 +2412,9 @@ export class TrackList
|
|||||||
<div class="sr-only" role="status" aria-live="polite">
|
<div class="sr-only" role="status" aria-live="polite">
|
||||||
${this.liveStatus(visibleTracks.length)}
|
${this.liveStatus(visibleTracks.length)}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sr-only" role="status" aria-live="polite">
|
||||||
|
${this.swipe.announcement}
|
||||||
|
</div>
|
||||||
${this.tracks.length === 0
|
${this.tracks.length === 0
|
||||||
? this.renderPlaceholder()
|
? this.renderPlaceholder()
|
||||||
: html`
|
: html`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
*
|
*
|
||||||
* `yj-tap` a short press that did not drift
|
* `yj-tap` a short press that did not drift
|
||||||
* `yj-long-press` a press that held still for LONG_PRESS_MS
|
* `yj-long-press` a press that held still for LONG_PRESS_MS
|
||||||
|
* `yj-swipe-start` a press that has travelled decisively sideways
|
||||||
|
*
|
||||||
|
* A claimed swipe is then followed by `yj-swipe-move` and one
|
||||||
|
* `yj-swipe-end`, which is guaranteed: a swipe that the browser or a
|
||||||
|
* second finger takes away still ends, with `canceled` set, so the
|
||||||
|
* affordance a component put on screen always has something to snap
|
||||||
|
* back from.
|
||||||
*
|
*
|
||||||
* A component that wants the gesture handles it and calls
|
* A component that wants the gesture handles it and calls
|
||||||
* `preventDefault()`. Nothing else changes. That shape is what lets
|
* `preventDefault()`. Nothing else changes. That shape is what lets
|
||||||
@@ -80,6 +87,62 @@
|
|||||||
* **The click swallow is keyed on the gesture**, cleared by the next
|
* **The click swallow is keyed on the gesture**, cleared by the next
|
||||||
* `pointerdown` rather than by a time window, so the first tap on a
|
* `pointerdown` rather than by a time window, so the first tap on a
|
||||||
* sheet that just opened is not eaten too.
|
* sheet that just opened is not eaten too.
|
||||||
|
*
|
||||||
|
* ## The swipe runs on touch events, and that is not a style choice
|
||||||
|
*
|
||||||
|
* Everything above is Pointer Events. The swipe is not, and the reason
|
||||||
|
* is measured on the reference device rather than reasoned about:
|
||||||
|
* **Chrome 113's Android WebView cancels the pointer stream ~16px into
|
||||||
|
* any drag, whatever `touch-action` says.** Three values were tried on
|
||||||
|
* a track row, driving a real finger with `adb shell input swipe`:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* touch-action: auto pointerdown, 1 move, pointercancel
|
||||||
|
* touch-action: pan-y pointerdown, 2 moves, pointercancel
|
||||||
|
* touch-action: none pointerdown, 2 moves, pointercancel
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `touchmove` kept firing throughout all three. So a swipe recognised
|
||||||
|
* from `pointermove` is a swipe that dies 16px in — plan 019 predicted
|
||||||
|
* the class of failure ("works in Chromium and not on the phone") and
|
||||||
|
* named `touch-action: pan-y` as the fix; it is half of it.
|
||||||
|
*
|
||||||
|
* The other half is that **a non-passive `touchmove` that calls
|
||||||
|
* `preventDefault()` is what keeps the gesture ours**. With it, the
|
||||||
|
* same swipe ran to 12 moves and a `pointerup` at full travel.
|
||||||
|
*
|
||||||
|
* Both halves are required, and that was measured too: with the
|
||||||
|
* `preventDefault` in place but `touch-action` back at `auto`, the
|
||||||
|
* gesture died after **one** move. The reading is that `auto` lets the
|
||||||
|
* browser commit to a horizontal pan on the first move past slop —
|
||||||
|
* before any threshold of ours can have been crossed — while `pan-y`
|
||||||
|
* leaves it undecided long enough for the second move to claim it.
|
||||||
|
*
|
||||||
|
* So a surface that wants a horizontal swipe declares
|
||||||
|
* `touch-action: pan-y` (`track-list`'s `.track-row` does) *and* gets
|
||||||
|
* this module's `preventDefault`. Neither alone works on the device,
|
||||||
|
* and **both work in Chromium either way**, which is exactly why this
|
||||||
|
* paragraph exists rather than a test.
|
||||||
|
*
|
||||||
|
* `touch-action: none` is the one value to avoid: it also takes the
|
||||||
|
* list's vertical scrolling away, which was measured as a list that
|
||||||
|
* would not move.
|
||||||
|
*
|
||||||
|
* Two consequences of the touch listener worth knowing.
|
||||||
|
*
|
||||||
|
* **It is non-passive, which costs the compositor's scroll fast path**
|
||||||
|
* for the first touchmoves of every scroll, until the browser starts
|
||||||
|
* scrolling and stops waiting on us. That is the standard price of a
|
||||||
|
* horizontal gesture in a scroller and it is paid once per gesture,
|
||||||
|
* not per frame; a vertical drag on the device still scrolls the
|
||||||
|
* virtualizer 81px on the same measurement that the horizontal one
|
||||||
|
* survives.
|
||||||
|
*
|
||||||
|
* **The tie breaks toward scrolling**, deliberately and in that order:
|
||||||
|
* vertical drift past the tolerance vetoes the swipe outright, and a
|
||||||
|
* gesture that is not *strictly* more horizontal than vertical is the
|
||||||
|
* scroller's. A list that will not scroll is unusable; a swipe that
|
||||||
|
* needs a second try is not.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** How long a press must hold still to mean "long press". */
|
/** How long a press must hold still to mean "long press". */
|
||||||
@@ -92,6 +155,18 @@ export const LONG_PRESS_MS = 500;
|
|||||||
*/
|
*/
|
||||||
export const MOVE_TOLERANCE_PX = 10;
|
export const MOVE_TOLERANCE_PX = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a press must travel sideways before it is a swipe.
|
||||||
|
*
|
||||||
|
* It has a ceiling the other constants do not: the browser's own
|
||||||
|
* decision is made a little past this, so a threshold much higher is a
|
||||||
|
* gesture the device never delivers. Measured, the second `touchmove`
|
||||||
|
* of an `adb input swipe` lands at ~19px and the pointer stream dies
|
||||||
|
* just after it, so 12 is inside that window with room for a slower
|
||||||
|
* finger.
|
||||||
|
*/
|
||||||
|
export const SWIPE_START_PX = 12;
|
||||||
|
|
||||||
/** Detail carried by both gesture events. */
|
/** Detail carried by both gesture events. */
|
||||||
export interface GestureDetail {
|
export interface GestureDetail {
|
||||||
/** Where the finger was, in client coordinates — a menu opens here. */
|
/** Where the finger was, in client coordinates — a menu opens here. */
|
||||||
@@ -99,12 +174,30 @@ export interface GestureDetail {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Detail carried by the three swipe events. */
|
||||||
|
export interface SwipeDetail {
|
||||||
|
/** Travel from where the finger landed. Signed: right is positive. */
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
/**
|
||||||
|
* The gesture was taken away rather than finished — a second
|
||||||
|
* finger, a `touchcancel`, a scroll underneath. Only ever true on
|
||||||
|
* `yj-swipe-end`, and it is the difference between "do the thing"
|
||||||
|
* and "put the row back".
|
||||||
|
*/
|
||||||
|
canceled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type GestureEvent = CustomEvent<GestureDetail>;
|
export type GestureEvent = CustomEvent<GestureDetail>;
|
||||||
|
export type SwipeEvent = CustomEvent<SwipeDetail>;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementEventMap {
|
interface HTMLElementEventMap {
|
||||||
'yj-tap': GestureEvent;
|
'yj-tap': GestureEvent;
|
||||||
'yj-long-press': GestureEvent;
|
'yj-long-press': GestureEvent;
|
||||||
|
'yj-swipe-start': SwipeEvent;
|
||||||
|
'yj-swipe-move': SwipeEvent;
|
||||||
|
'yj-swipe-end': SwipeEvent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,10 +232,44 @@ export function installTouchGestures(): () => void {
|
|||||||
* anything. */
|
* anything. */
|
||||||
let swallowClick = false;
|
let swallowClick = false;
|
||||||
|
|
||||||
/** We dispatched a `contextmenu`, so a trusted one arriving now is
|
/**
|
||||||
* a duplicate. */
|
* This press has already produced its outcome, so a trusted
|
||||||
|
* `contextmenu` arriving now is a duplicate of it.
|
||||||
|
*
|
||||||
|
* It covers **both** outcomes, and that is a fix rather than a
|
||||||
|
* tidy-up. `nativeSeen` handles the browser's menu arriving
|
||||||
|
* *during* the hold; the reverse order was never handled, and it
|
||||||
|
* happens: measured on the reference device over four holds, two
|
||||||
|
* of them fired our 500ms timer and then delivered a trusted
|
||||||
|
* `contextmenu` 50-70ms later, which nothing suppressed — so the
|
||||||
|
* context menu opened on top of the selection bar, intermittently,
|
||||||
|
* on exactly the surface #63 exists to have changed. Neither the
|
||||||
|
* component tier nor the e2e tier can see it: no browser they run
|
||||||
|
* in synthesises a `contextmenu` from a dispatched press at all.
|
||||||
|
*/
|
||||||
let justFired = false;
|
let justFired = false;
|
||||||
|
|
||||||
|
// --- the swipe, which runs on touch events; see the header ------
|
||||||
|
|
||||||
|
/** Where the finger landed, and what it landed on. */
|
||||||
|
let swipeTarget: EventTarget | null = null;
|
||||||
|
let swipeOriginX = 0;
|
||||||
|
let swipeOriginY = 0;
|
||||||
|
|
||||||
|
/** The last travel, kept so a `touchcancel` — which carries no
|
||||||
|
* coordinates for a touch that is already gone — can still say how
|
||||||
|
* far the row had moved. */
|
||||||
|
let lastDx = 0;
|
||||||
|
let lastDy = 0;
|
||||||
|
|
||||||
|
/** A component claimed the swipe: it is ours until the finger
|
||||||
|
* lifts, and every `touchmove` is prevented. */
|
||||||
|
let swiping = false;
|
||||||
|
|
||||||
|
/** This press can no longer become a swipe — it went vertical, a
|
||||||
|
* second finger arrived, or nobody claimed it. */
|
||||||
|
let swipeVetoed = false;
|
||||||
|
|
||||||
const cancel = (): void => {
|
const cancel = (): void => {
|
||||||
if (timer !== null) clearTimeout(timer);
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
|
||||||
@@ -202,6 +329,12 @@ export function installTouchGestures(): () => void {
|
|||||||
// selection mode or a card grid let it fall through to a menu.
|
// selection mode or a card grid let it fall through to a menu.
|
||||||
swallowClick = true;
|
swallowClick = true;
|
||||||
|
|
||||||
|
// The press is answered, so a trusted `contextmenu` for it is
|
||||||
|
// late rather than new. `fireContextMenu` sets this too; it is
|
||||||
|
// set here as well so the *claimed* branch is covered, which
|
||||||
|
// is the branch that was showing a menu over the bar.
|
||||||
|
justFired = true;
|
||||||
|
|
||||||
// An unclaimed long press is what it has always been. This is
|
// An unclaimed long press is what it has always been. This is
|
||||||
// the whole reason the fourteen context menus need no change.
|
// the whole reason the fourteen context menus need no change.
|
||||||
if (!announce('yj-long-press', el)) fireContextMenu(el);
|
if (!announce('yj-long-press', el)) fireContextMenu(el);
|
||||||
@@ -223,6 +356,145 @@ export function installTouchGestures(): () => void {
|
|||||||
timer = setTimeout(onLongPress, LONG_PRESS_MS);
|
timer = setTimeout(onLongPress, LONG_PRESS_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce a swipe on the element the finger landed on.
|
||||||
|
* Returns whether a component claimed it (only `start` asks).
|
||||||
|
*/
|
||||||
|
const announceSwipe = (
|
||||||
|
name: 'yj-swipe-start' | 'yj-swipe-move' | 'yj-swipe-end',
|
||||||
|
el: EventTarget,
|
||||||
|
canceled = false,
|
||||||
|
): boolean => {
|
||||||
|
const event: SwipeEvent = new CustomEvent<SwipeDetail>(name, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: name === 'yj-swipe-start',
|
||||||
|
composed: true,
|
||||||
|
detail: { dx: lastDx, dy: lastDy, canceled },
|
||||||
|
});
|
||||||
|
|
||||||
|
ours.add(event);
|
||||||
|
el.dispatchEvent(event);
|
||||||
|
|
||||||
|
return event.defaultPrevented;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a claimed swipe, once.
|
||||||
|
*
|
||||||
|
* Every exit from a swipe comes through here so that `yj-swipe-end`
|
||||||
|
* is guaranteed: a component that has put a reveal on screen and a
|
||||||
|
* row half off its own left edge has no other way to learn the
|
||||||
|
* gesture is over.
|
||||||
|
*/
|
||||||
|
const endSwipe = (canceled: boolean): void => {
|
||||||
|
const el = swipeTarget;
|
||||||
|
|
||||||
|
swipeTarget = null;
|
||||||
|
|
||||||
|
if (!swiping) return;
|
||||||
|
|
||||||
|
swiping = false;
|
||||||
|
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// The gesture happened, so the click that ends it is not a
|
||||||
|
// click on the row it ended over.
|
||||||
|
swallowClick = true;
|
||||||
|
announceSwipe('yj-swipe-end', el, canceled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent): void => {
|
||||||
|
endSwipe(true);
|
||||||
|
|
||||||
|
lastDx = 0;
|
||||||
|
lastDy = 0;
|
||||||
|
|
||||||
|
// A second finger is a pinch or a scroll, never one of ours.
|
||||||
|
swipeVetoed = e.touches.length !== 1;
|
||||||
|
|
||||||
|
if (swipeVetoed) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
swipeOriginX = touch.clientX;
|
||||||
|
swipeOriginY = touch.clientY;
|
||||||
|
// `composedPath()[0]` for the reason the press path uses it: a
|
||||||
|
// list delegates inside its own shadow root.
|
||||||
|
swipeTarget = e.composedPath()[0] ?? e.target;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: TouchEvent): void => {
|
||||||
|
if (swipeVetoed || !swipeTarget) return;
|
||||||
|
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
endSwipe(true);
|
||||||
|
swipeVetoed = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
lastDx = touch.clientX - swipeOriginX;
|
||||||
|
lastDy = touch.clientY - swipeOriginY;
|
||||||
|
|
||||||
|
if (swiping) {
|
||||||
|
// This is what keeps the stream alive on the device. It is
|
||||||
|
// only ever reached for a *claimed* swipe, so nothing that
|
||||||
|
// scrolls is ever prevented.
|
||||||
|
e.preventDefault();
|
||||||
|
announceSwipe('yj-swipe-move', swipeTarget);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical first: past the tolerance the list has it, and a
|
||||||
|
// gesture that is exactly diagonal is the list's too.
|
||||||
|
if (
|
||||||
|
Math.abs(lastDy) > MOVE_TOLERANCE_PX &&
|
||||||
|
Math.abs(lastDy) >= Math.abs(lastDx)
|
||||||
|
) {
|
||||||
|
swipeVetoed = true;
|
||||||
|
swipeTarget = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(lastDx) < SWIPE_START_PX ||
|
||||||
|
Math.abs(lastDx) <= Math.abs(lastDy)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!announceSwipe('yj-swipe-start', swipeTarget)) {
|
||||||
|
// Nobody wants it. Leave the gesture to the browser rather
|
||||||
|
// than holding it open for the rest of the press.
|
||||||
|
swipeVetoed = true;
|
||||||
|
swipeTarget = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
swiping = true;
|
||||||
|
|
||||||
|
// It is not a tap and it is not a hold.
|
||||||
|
cancel();
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = (): void => {
|
||||||
|
endSwipe(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchCancel = (): void => {
|
||||||
|
endSwipe(true);
|
||||||
|
};
|
||||||
|
|
||||||
const onPointerMove = (e: PointerEvent): void => {
|
const onPointerMove = (e: PointerEvent): void => {
|
||||||
if (timer === null) return;
|
if (timer === null) return;
|
||||||
|
|
||||||
@@ -301,25 +573,45 @@ export function installTouchGestures(): () => void {
|
|||||||
// before anything that would act on the event.
|
// before anything that would act on the event.
|
||||||
const opts = { capture: true } as const;
|
const opts = { capture: true } as const;
|
||||||
|
|
||||||
|
// Non-passive, because `onTouchMove` has to be able to prevent the
|
||||||
|
// default for a claimed swipe -- see the header. The other three
|
||||||
|
// are passive: they only read.
|
||||||
|
const blocking = { capture: true, passive: false } as const;
|
||||||
|
const listening = { capture: true, passive: true } as const;
|
||||||
|
|
||||||
|
/** A surface moved under the finger: neither gesture survives it. */
|
||||||
|
const abort = (): void => {
|
||||||
|
endSwipe(true);
|
||||||
|
cancel();
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('pointerdown', onPointerDown, opts);
|
document.addEventListener('pointerdown', onPointerDown, opts);
|
||||||
document.addEventListener('pointermove', onPointerMove, opts);
|
document.addEventListener('pointermove', onPointerMove, opts);
|
||||||
document.addEventListener('pointerup', onPointerUp, opts);
|
document.addEventListener('pointerup', onPointerUp, opts);
|
||||||
document.addEventListener('pointercancel', cancel, opts);
|
document.addEventListener('pointercancel', cancel, opts);
|
||||||
document.addEventListener('contextmenu', onContextMenu, opts);
|
document.addEventListener('contextmenu', onContextMenu, opts);
|
||||||
document.addEventListener('click', onClick, opts);
|
document.addEventListener('click', onClick, opts);
|
||||||
|
document.addEventListener('touchstart', onTouchStart, listening);
|
||||||
|
document.addEventListener('touchmove', onTouchMove, blocking);
|
||||||
|
document.addEventListener('touchend', onTouchEnd, listening);
|
||||||
|
document.addEventListener('touchcancel', onTouchCancel, listening);
|
||||||
// A scroll started by something other than the finger (momentum, a
|
// A scroll started by something other than the finger (momentum, a
|
||||||
// programmatic reveal) still means the press was not a press.
|
// programmatic reveal) still means the press was not a press.
|
||||||
document.addEventListener('scroll', cancel, { capture: true, passive: true });
|
document.addEventListener('scroll', abort, listening);
|
||||||
|
|
||||||
uninstall = () => {
|
uninstall = () => {
|
||||||
cancel();
|
abort();
|
||||||
document.removeEventListener('pointerdown', onPointerDown, opts);
|
document.removeEventListener('pointerdown', onPointerDown, opts);
|
||||||
document.removeEventListener('pointermove', onPointerMove, opts);
|
document.removeEventListener('pointermove', onPointerMove, opts);
|
||||||
document.removeEventListener('pointerup', onPointerUp, opts);
|
document.removeEventListener('pointerup', onPointerUp, opts);
|
||||||
document.removeEventListener('pointercancel', cancel, opts);
|
document.removeEventListener('pointercancel', cancel, opts);
|
||||||
document.removeEventListener('contextmenu', onContextMenu, opts);
|
document.removeEventListener('contextmenu', onContextMenu, opts);
|
||||||
document.removeEventListener('click', onClick, opts);
|
document.removeEventListener('click', onClick, opts);
|
||||||
document.removeEventListener('scroll', cancel, opts);
|
document.removeEventListener('touchstart', onTouchStart, opts);
|
||||||
|
document.removeEventListener('touchmove', onTouchMove, opts);
|
||||||
|
document.removeEventListener('touchend', onTouchEnd, opts);
|
||||||
|
document.removeEventListener('touchcancel', onTouchCancel, opts);
|
||||||
|
document.removeEventListener('scroll', abort, opts);
|
||||||
uninstall = null;
|
uninstall = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -137,6 +137,11 @@ describe('<app-sidebar>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('looks the way it did last time', async () => {
|
it('looks the way it did last time', async () => {
|
||||||
|
// Stated rather than inherited: `activeViewStore` is a singleton, so
|
||||||
|
// without this the shot records whichever view the *previous* case
|
||||||
|
// left in it and the reference moves when the file is reordered.
|
||||||
|
activeViewStore.setView('home', true);
|
||||||
|
|
||||||
const el = await fixture('app-sidebar');
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
await visual(el, 'app-sidebar');
|
await visual(el, 'app-sidebar');
|
||||||
|
|||||||
@@ -366,6 +366,16 @@ describe('<now-playing>', () => {
|
|||||||
const el = await fixture('now-playing');
|
const el = await fixture('now-playing');
|
||||||
|
|
||||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
|
||||||
|
// Stated rather than inherited: the queue store is a singleton, so
|
||||||
|
// without this the shot records whichever source the *previous*
|
||||||
|
// case left in it and the reference moves when the file is
|
||||||
|
// reordered. Three lines is what the bar renders while playing
|
||||||
|
// from somewhere, which is the arrangement worth recording.
|
||||||
|
setQueue([queueTrack(1, 'Ashes to Ashes')], 0, {
|
||||||
|
type: 'album',
|
||||||
|
id: 7,
|
||||||
|
label: 'Scary Monsters',
|
||||||
|
});
|
||||||
await flush();
|
await flush();
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,15 @@ describe('double-clicking a row in the track list', () => {
|
|||||||
localStorage.removeItem('track-list-column-widths');
|
localStorage.removeItem('track-list-column-widths');
|
||||||
|
|
||||||
el = await fixture<LitElement>('track-list', { externalTracks: LIST });
|
el = await fixture<LitElement>('track-list', { externalTracks: LIST });
|
||||||
|
|
||||||
|
// Say which order is being asserted rather than inheriting one.
|
||||||
|
// `restoreSortPreferences()` runs in `connectedCallback`, so the
|
||||||
|
// list opens in whatever sort was last persisted -- and
|
||||||
|
// `track-11` sorts before `track-3` by title, which is the shape
|
||||||
|
// of #138. The row below is the fixture's third track only while
|
||||||
|
// nothing is sorting the list.
|
||||||
|
(el as unknown as { sortField: string | null }).sortField = null;
|
||||||
|
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
el.style.height = '600px';
|
el.style.height = '600px';
|
||||||
await flush();
|
await flush();
|
||||||
@@ -172,6 +181,10 @@ describe('double-clicking a row in the track list', () => {
|
|||||||
const rows = shadowAll(el, '.track-row');
|
const rows = shadowAll(el, '.track-row');
|
||||||
const row = rows.find((r) => r.getAttribute('data-index') === '3');
|
const row = rows.find((r) => r.getAttribute('data-index') === '3');
|
||||||
|
|
||||||
|
// Stated first, so a list that is not in the order this asserts
|
||||||
|
// fails by saying so rather than as an off-by-eight file path.
|
||||||
|
expect(row?.getAttribute('data-file-path')).toBe('/music/track-3.mp3');
|
||||||
|
|
||||||
dblclick(row!);
|
dblclick(row!);
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
|
|||||||
@@ -424,6 +424,24 @@ describe("the browser's own long press", () => {
|
|||||||
expect(menus).toHaveLength(0);
|
expect(menus).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suppresses a menu that arrives after the hold was claimed', async () => {
|
||||||
|
// The order the device actually produces, and the one that was
|
||||||
|
// missing: our 500ms timer fires first and a component claims it,
|
||||||
|
// then Chrome delivers its own `contextmenu` 50-70ms later.
|
||||||
|
// Measured over four holds on the reference phone, two took this
|
||||||
|
// order -- so the context menu opened over the selection bar,
|
||||||
|
// intermittently, on the one surface #63 changed.
|
||||||
|
const menus = recordMenus(inner);
|
||||||
|
|
||||||
|
inner.addEventListener('yj-long-press', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
await wait(HELD);
|
||||||
|
browserContextMenu(inner);
|
||||||
|
|
||||||
|
expect(menus, 'the menu is late, not new').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('still opens exactly one menu when nobody claims it', async () => {
|
it('still opens exactly one menu when nobody claims it', async () => {
|
||||||
// The old behaviour, reached by asking instead of assuming. This
|
// The old behaviour, reached by asking instead of assuming. This
|
||||||
// is what leaves the card grids, Explore and the playlist rows
|
// is what leaves the card grids, Explore and the playlist rows
|
||||||
@@ -438,3 +456,226 @@ describe("the browser's own long press", () => {
|
|||||||
expect(menus).toHaveLength(1);
|
expect(menus).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The swipe half (plan 019 phase 2, #63).
|
||||||
|
*
|
||||||
|
* It runs on touch events rather than pointer events, and that is the
|
||||||
|
* one thing about it a browser tier cannot check. Measured on the
|
||||||
|
* reference device: Chrome 113's WebView cancels the *pointer* stream
|
||||||
|
* ~16px into any drag whatever `touch-action` says, while `touchmove`
|
||||||
|
* keeps firing — so what these assert is the shape that survives it,
|
||||||
|
* not that it survives.
|
||||||
|
*
|
||||||
|
* What they can hold is everything else: the axis rule, that the tie
|
||||||
|
* breaks toward the scroller, that an unclaimed swipe is left entirely
|
||||||
|
* alone, that a claimed one prevents the default (which is the half of
|
||||||
|
* the device fix that lives in code), and that an end always arrives.
|
||||||
|
*/
|
||||||
|
describe('a finger dragged sideways', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
uninstall = installTouchGestures();
|
||||||
|
({ host, inner } = mountRow());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
uninstall?.();
|
||||||
|
uninstall = null;
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One finger, at an offset from where it landed. */
|
||||||
|
function touch(el: EventTarget, type: string, dx = 0, dy = 0): TouchEvent {
|
||||||
|
const point = new Touch({
|
||||||
|
identifier: 1,
|
||||||
|
target: el as EventTarget as Element,
|
||||||
|
clientX: 40 + dx,
|
||||||
|
clientY: 60 + dy,
|
||||||
|
});
|
||||||
|
const event = new TouchEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
cancelable: true,
|
||||||
|
touches: type === 'touchend' || type === 'touchcancel' ? [] : [point],
|
||||||
|
changedTouches: [point],
|
||||||
|
});
|
||||||
|
|
||||||
|
el.dispatchEvent(event);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the swipe events a component would bind. */
|
||||||
|
function recordSwipes(
|
||||||
|
el: EventTarget,
|
||||||
|
opts: { claim?: boolean } = {},
|
||||||
|
): { type: string; dx: number; canceled: boolean }[] {
|
||||||
|
const seen: { type: string; dx: number; canceled: boolean }[] = [];
|
||||||
|
|
||||||
|
for (const name of ['yj-swipe-start', 'yj-swipe-move', 'yj-swipe-end']) {
|
||||||
|
el.addEventListener(name, (e) => {
|
||||||
|
const detail = (e as CustomEvent<{ dx: number; canceled: boolean }>)
|
||||||
|
.detail;
|
||||||
|
|
||||||
|
if (name === 'yj-swipe-start' && opts.claim !== false) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.push({ type: name, dx: detail.dx, canceled: detail.canceled });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('announces a swipe once it has travelled decisively sideways', () => {
|
||||||
|
const seen = recordSwipes(inner);
|
||||||
|
|
||||||
|
touch(inner, 'touchstart');
|
||||||
|
touch(inner, 'touchmove', 4);
|
||||||
|
expect(seen, 'a wobble is not a swipe').toHaveLength(0);
|
||||||
|
|
||||||
|
touch(inner, 'touchmove', 30);
|
||||||
|
touch(inner, 'touchmove', 60);
|
||||||
|
touch(inner, 'touchend');
|
||||||
|
|
||||||
|
expect(seen.map((s) => s.type)).toEqual([
|
||||||
|
'yj-swipe-start',
|
||||||
|
'yj-swipe-move',
|
||||||
|
'yj-swipe-end',
|
||||||
|
]);
|
||||||
|
expect(seen.at(-1)?.dx).toBe(60);
|
||||||
|
expect(seen.at(-1)?.canceled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents the default only for a claimed swipe', () => {
|
||||||
|
// This is the half of the device fix that lives in code: a
|
||||||
|
// non-passive `touchmove` calling `preventDefault` is what keeps
|
||||||
|
// the gesture ours on Chrome 113. Preventing anything else would
|
||||||
|
// be taking the list's scrolling away.
|
||||||
|
recordSwipes(inner);
|
||||||
|
|
||||||
|
touch(inner, 'touchstart');
|
||||||
|
|
||||||
|
const early = touch(inner, 'touchmove', 4);
|
||||||
|
|
||||||
|
expect(early.defaultPrevented, 'a wobble scrolls').toBe(false);
|
||||||
|
|
||||||
|
const claimed = touch(inner, 'touchmove', 30);
|
||||||
|
const after = touch(inner, 'touchmove', 60);
|
||||||
|
|
||||||
|
expect(claimed.defaultPrevented).toBe(true);
|
||||||
|
expect(after.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unclaimed swipe entirely alone', () => {
|
||||||
|
const seen = recordSwipes(inner, { claim: false });
|
||||||
|
|
||||||
|
touch(inner, 'touchstart');
|
||||||
|
touch(inner, 'touchmove', 30);
|
||||||
|
|
||||||
|
const later = touch(inner, 'touchmove', 60);
|
||||||
|
|
||||||
|
touch(inner, 'touchend');
|
||||||
|
|
||||||
|
// Asked once, refused, and then not asked again for the rest of
|
||||||
|
// the press -- and nothing prevented, so the browser still owns it.
|
||||||
|
expect(seen.map((s) => s.type)).toEqual(['yj-swipe-start']);
|
||||||
|
expect(later.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a drag that went vertical to the scroller, and keeps it', () => {
|
||||||
|
// The veto is a *latch*, and that is the whole of it: a scroll
|
||||||
|
// that curves — which is what a thumb does — would otherwise
|
||||||
|
// become a swipe halfway down the list, snatching the list out
|
||||||
|
// from under itself. Without the latch the second move here is
|
||||||
|
// decisively horizontal and would claim the gesture.
|
||||||
|
const seen = recordSwipes(inner);
|
||||||
|
|
||||||
|
touch(inner, 'touchstart');
|
||||||
|
touch(inner, 'touchmove', 4, 30);
|
||||||
|
touch(inner, 'touchmove', 80, 35);
|
||||||
|
touch(inner, 'touchend');
|
||||||
|
|
||||||
|
expect(seen, 'the list has it').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the scroller the tie as well', () => {
|
||||||
|
// Exactly diagonal is not "decisively sideways". A list that will
|
||||||
|
// not scroll is unusable; a swipe that needs a second try is not.
|
||||||
|
const seen = recordSwipes(inner);
|
||||||
|
|
||||||
|
touch(inner, 'touchstart');
|
||||||
|
touch(inner, 'touchmove', 60, 60);
|
||||||
|
touch(inner, 'touchend');
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not a tap and not a hold once it is a swipe', async () => {
|
||||||
|
const menus = recordMenus(inner);
|
||||||
|
const taps: Event[] = [];
|
||||||
|
|
||||||
|
inner.addEventListener('yj-tap', (e) => taps.push(e));
|
||||||
|
recordSwipes(inner);
|
||||||
|
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
touch(inner, 'touchstart');
|
||||||
|
touch(inner, 'touchmove', 40);
|
||||||
|
await wait(HELD);
|
||||||
|
touch(inner, 'touchend');
|
||||||
|
press(inner, 'pointerup');
|
||||||
|
|
||||||
|
expect(menus, 'the hold did not become a menu').toHaveLength(0);
|
||||||
|
expect(taps, 'the lift did not become a tap').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always ends, even when the gesture is taken away', () => {
|
||||||
|
// A component that has a row half off its own left edge has no
|
||||||
|
// other way to learn the finger is gone -- so `touchcancel` is an
|
||||||
|
// end with `canceled` set, not a silence.
|
||||||
|
const seen = recordSwipes(inner);
|
||||||
|
|
||||||
|
touch(inner, 'touchstart');
|
||||||
|
touch(inner, 'touchmove', 40);
|
||||||
|
touch(inner, 'touchcancel');
|
||||||
|
|
||||||
|
expect(seen.at(-1)?.type).toBe('yj-swipe-end');
|
||||||
|
expect(seen.at(-1)?.canceled).toBe(true);
|
||||||
|
expect(seen.at(-1)?.dx).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is never one gesture when there are two fingers', () => {
|
||||||
|
const seen = recordSwipes(inner);
|
||||||
|
const two = new TouchEvent('touchstart', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
cancelable: true,
|
||||||
|
touches: [
|
||||||
|
new Touch({ identifier: 1, target: inner, clientX: 40, clientY: 60 }),
|
||||||
|
new Touch({ identifier: 2, target: inner, clientX: 90, clientY: 60 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
inner.dispatchEvent(two);
|
||||||
|
touch(inner, 'touchmove', 60);
|
||||||
|
|
||||||
|
expect(seen, 'a pinch is not a swipe').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows the click a claimed swipe ends on', () => {
|
||||||
|
const clicks: Event[] = [];
|
||||||
|
|
||||||
|
recordSwipes(inner);
|
||||||
|
inner.addEventListener('click', (e) => clicks.push(e));
|
||||||
|
|
||||||
|
touch(inner, 'touchstart');
|
||||||
|
touch(inner, 'touchmove', 40);
|
||||||
|
touch(inner, 'touchend');
|
||||||
|
inner.dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clicks, 'the row was not also clicked').toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import { fixture, shadow, shadowAll } from '@test/support/render';
|
|||||||
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
|
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
|
||||||
|
|
||||||
const HELD = LONG_PRESS_MS + 120;
|
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 BRIEF = Math.round(LONG_PRESS_MS / 4);
|
||||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
@@ -39,6 +42,12 @@ function track(n: number) {
|
|||||||
Album: 'An Album',
|
Album: 'An Album',
|
||||||
Duration: 100 + n,
|
Duration: 100 + n,
|
||||||
ID: 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) {
|
async function tap(el: EventTarget) {
|
||||||
press(el, 'pointerdown');
|
press(el, 'pointerdown');
|
||||||
await wait(BRIEF);
|
await wait(BRIEF);
|
||||||
press(el, 'pointerup');
|
press(el, 'pointerup');
|
||||||
|
el.dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }),
|
||||||
|
);
|
||||||
await wait(0);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Swipe right on a track row to queue it (plan 019 phase 2, #63).
|
||||||
|
*
|
||||||
|
* `touch-gestures.test.ts` holds the recogniser — the axis rule, the
|
||||||
|
* claim, the guaranteed end. What is here is what the *list* does with
|
||||||
|
* it, and the two things that are only true of a list:
|
||||||
|
*
|
||||||
|
* **A swipe is not a selection.** It acts on the row it was made on,
|
||||||
|
* unless that row is one of several the user has explicitly chosen, in
|
||||||
|
* which case it acts on all of them — the same rule the context menu
|
||||||
|
* answers with, because a bar saying "40 selected" and a gesture that
|
||||||
|
* quietly queues one of them is two answers to the same question.
|
||||||
|
*
|
||||||
|
* **A short swipe is a no-op**, and that is the only thing standing
|
||||||
|
* between "add to queue" and a scroll that drifted sideways. The
|
||||||
|
* threshold is a fraction of the row, so it is measured from the row
|
||||||
|
* here rather than written down twice.
|
||||||
|
*
|
||||||
|
* What this tier cannot see is the device, and the reason is in the
|
||||||
|
* module's own header: Chrome 113's WebView cancels the pointer stream
|
||||||
|
* ~16px into any drag whatever `touch-action` says, so the gesture
|
||||||
|
* runs on touch events and needs `touch-action: pan-y` *and* a
|
||||||
|
* non-passive `preventDefault`. Both are correct in Chromium either
|
||||||
|
* way. The stylesheet half is asserted below for the same reason
|
||||||
|
* `hover-affordance.test.ts` reads a parsed stylesheet: the regression
|
||||||
|
* is someone tidying the declaration away, and nothing here renders
|
||||||
|
* differently when they do.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/track-list/track-list';
|
||||||
|
|
||||||
|
import { calls, flush, resetHarness, stub } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
|
import { installTouchGestures } from '@utils/touch-gestures';
|
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
let uninstall: (() => void) | null = null;
|
||||||
|
|
||||||
|
function track(n: number) {
|
||||||
|
return {
|
||||||
|
FilePath: `/music/track-${n}.mp3`,
|
||||||
|
TrackName: `Track ${n}`,
|
||||||
|
ArtistName: 'An Artist',
|
||||||
|
Album: 'An Album',
|
||||||
|
Duration: 100 + n,
|
||||||
|
ID: n,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACKS = [track(1), track(2), track(3), track(4)];
|
||||||
|
|
||||||
|
async function mountList() {
|
||||||
|
const el = await fixture('track-list');
|
||||||
|
|
||||||
|
// A definite width, because the commit threshold is a fraction of
|
||||||
|
// the row and a list that has not been given one is not a list.
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.width = '400px';
|
||||||
|
|
||||||
|
(el as unknown as { tracks: unknown[] }).tracks = TRACKS;
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
await wait(60);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows(el: HTMLElement): HTMLElement[] {
|
||||||
|
return shadowAll<HTMLElement>(el, '.track-row');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag a row sideways by `dx` and lift, as one finger.
|
||||||
|
*
|
||||||
|
* `onStep` runs after each move and is awaited, which is how the
|
||||||
|
* reveal is observed: the component writes the travel straight to the
|
||||||
|
* row's style but renders the pane through Lit, so it exists a frame
|
||||||
|
* after the move that asked for it, not during it.
|
||||||
|
*/
|
||||||
|
async function swipe(
|
||||||
|
el: EventTarget,
|
||||||
|
dx: number,
|
||||||
|
dy = 0,
|
||||||
|
onStep?: () => Promise<void> | void,
|
||||||
|
): Promise<void> {
|
||||||
|
const at = (x: number, y: number) =>
|
||||||
|
new Touch({
|
||||||
|
identifier: 1,
|
||||||
|
target: el as Element,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
});
|
||||||
|
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, 0)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
send('touchstart', [at(0, 100)]);
|
||||||
|
|
||||||
|
// Several steps, because the recogniser claims the gesture on the
|
||||||
|
// move that crosses its threshold and the component reads every one
|
||||||
|
// after it.
|
||||||
|
for (const step of [0.25, 0.5, 0.75, 1]) {
|
||||||
|
send('touchmove', [at(dx * step, 100 + dy * step)]);
|
||||||
|
if (onStep) await onStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
send('touchend', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the commit threshold falls for the row as rendered. */
|
||||||
|
function threshold(row: HTMLElement): number {
|
||||||
|
return Math.max(72, row.getBoundingClientRect().width * 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a finger swiped right across a track row', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub('library.Library.GetTracks', TRACKS);
|
||||||
|
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||||
|
stub('config.Config.GetShortcuts', {});
|
||||||
|
stub('queue.Queue.SetQueue', null);
|
||||||
|
stub('queue.Queue.AddTracks', null);
|
||||||
|
uninstall = installTouchGestures();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
uninstall?.();
|
||||||
|
uninstall = null;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds that row to the queue', async () => {
|
||||||
|
const el = await mountList();
|
||||||
|
const row = rows(el)[1];
|
||||||
|
|
||||||
|
expect(row, 'the list rendered rows').toBeTruthy();
|
||||||
|
|
||||||
|
await swipe(row!, threshold(row!) + 40);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const queued = calls('queue.Queue.AddTracks');
|
||||||
|
|
||||||
|
expect(queued.length, 'one swipe, one call').toBe(1);
|
||||||
|
expect(queued[0]?.args[0]).toEqual(['/music/track-2.mp3']);
|
||||||
|
|
||||||
|
// It queues; it does not play. The row that was playing keeps
|
||||||
|
// playing, which is the difference from a tap.
|
||||||
|
expect(calls('queue.Queue.SetQueue').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the finger did not get far enough', async () => {
|
||||||
|
const el = await mountList();
|
||||||
|
const row = rows(el)[1];
|
||||||
|
|
||||||
|
await swipe(row!, Math.round(threshold(row!)) - 10);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(calls('queue.Queue.AddTracks').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a scroll that began on a row to the list', async () => {
|
||||||
|
// The same shape as `touch-selection.test.ts`'s "does not play a
|
||||||
|
// row the finger scrolled from", one gesture over: the failure
|
||||||
|
// this guards against makes the list unusable rather than wrong.
|
||||||
|
const el = await mountList();
|
||||||
|
const row = rows(el)[1];
|
||||||
|
|
||||||
|
await swipe(row!, 30, 200);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(calls('queue.Queue.AddTracks').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals what it will do, in words, while the finger is down', async () => {
|
||||||
|
const el = await mountList();
|
||||||
|
const row = rows(el)[1];
|
||||||
|
const reveals: (string | undefined)[] = [];
|
||||||
|
|
||||||
|
await swipe(row!, threshold(row!) + 40, 0, async () => {
|
||||||
|
await el.updateComplete;
|
||||||
|
reveals.push(
|
||||||
|
shadow(el, '[data-testid="swipe-reveal"]')?.textContent?.trim(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not only a colour (WCAG 1.4.1): the pane says what it is for,
|
||||||
|
// and says something different once the gesture would commit.
|
||||||
|
expect(reveals.some((t) => t?.includes('Add to queue'))).toBe(true);
|
||||||
|
expect(reveals.some((t) => t?.includes('Release to add'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what it did, for anyone not watching the row', async () => {
|
||||||
|
const el = await mountList();
|
||||||
|
const row = rows(el)[1];
|
||||||
|
|
||||||
|
await swipe(row!, threshold(row!) + 40);
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const said = shadowAll(el, '[role="status"]')
|
||||||
|
.map((r) => r.textContent?.trim())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
expect(said).toContain('Track 2');
|
||||||
|
expect(said).toContain('queue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues the whole selection when the row is part of one', async () => {
|
||||||
|
// One row is a position; several rows are an explicit choice. A
|
||||||
|
// gesture that quietly queued the one row touched would contradict
|
||||||
|
// the bar above it saying how many are selected.
|
||||||
|
const el = await mountList();
|
||||||
|
|
||||||
|
rows(el)[1]?.dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||||
|
);
|
||||||
|
rows(el)[3]?.dispatchEvent(
|
||||||
|
new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
ctrlKey: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const row = rows(el)[1];
|
||||||
|
|
||||||
|
await swipe(row!, threshold(row!) + 40);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(calls('queue.Queue.AddTracks')[0]?.args[0]).toEqual([
|
||||||
|
'/music/track-2.mp3',
|
||||||
|
'/music/track-4.mp3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues only the row it touched when that row is outside the selection', async () => {
|
||||||
|
const el = await mountList();
|
||||||
|
|
||||||
|
rows(el)[3]?.dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||||
|
);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const row = rows(el)[0];
|
||||||
|
|
||||||
|
await swipe(row!, threshold(row!) + 40);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(calls('queue.Queue.AddTracks')[0]?.args[0]).toEqual([
|
||||||
|
'/music/track-1.mp3',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// And it did not become a way of selecting anything.
|
||||||
|
expect(rows(el)[3]?.getAttribute('aria-selected')).toBe('true');
|
||||||
|
expect(rows(el)[0]?.getAttribute('aria-selected')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }[];
|
||||||
|
}
|
||||||
|
).styles;
|
||||||
|
const css = sheets.map((s) => s.cssText).join('\n');
|
||||||
|
const rule = css
|
||||||
|
.split('}')
|
||||||
|
.find((block) => /\[data-swipe\]\s*\{/.test(block));
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,23 @@ document.body.style.margin = '0';
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
|
|
||||||
|
// A test file does not get its own origin. `@vitest/browser-playwright`
|
||||||
|
// opens one BrowserContext per session and runs several files in it,
|
||||||
|
// one after another, so everything a component persists — the track
|
||||||
|
// list's sort and column widths, the cover size, `now-playing`'s
|
||||||
|
// scroll mode — is still there when the next file mounts the same
|
||||||
|
// component. That is invisible until it is intermittent, because
|
||||||
|
// which files share a tab and in what order changes run to run: it
|
||||||
|
// cost #138 three scheduled runs, one of them a PR with no frontend
|
||||||
|
// code in it at all.
|
||||||
|
//
|
||||||
|
// Clearing here rather than in the specs that write is deliberate —
|
||||||
|
// the spec that *reads* is never the one that knows. It is safe for
|
||||||
|
// the same reason the leak exists: files in a session are
|
||||||
|
// sequential, so this cannot wipe storage a concurrent file is in
|
||||||
|
// the middle of using.
|
||||||
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ func TestFixturesMatchManifest(t *testing.T) {
|
|||||||
m := testfixtures.Load(t)
|
m := testfixtures.Load(t)
|
||||||
|
|
||||||
for _, want := range m.Tracks {
|
for _, want := range m.Tracks {
|
||||||
// WAV tags are write-only today; see
|
|
||||||
// TestWAVTagsAreNotReadableYet.
|
|
||||||
if want.Format == "wav" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run(want.Path, func(t *testing.T) {
|
t.Run(want.Path, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -161,39 +155,6 @@ func TestDuplicateFixturesAreIndistinguishable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWAVTagsAreNotReadableYet pins a known gap rather than hiding it.
|
|
||||||
//
|
|
||||||
// backend/tagwriter writes WAV tags into a RIFF "id3 " chunk, but
|
|
||||||
// backend/metadata reads through dhowden/tag, which recognises MP3,
|
|
||||||
// FLAC, OGG, MP4 and DSF and has no RIFF parser at all. So every tag
|
|
||||||
// the app writes to a WAV is invisible to the app that wrote it, and
|
|
||||||
// WAV tracks always scan in as untitled.
|
|
||||||
//
|
|
||||||
// The fixtures are tagged correctly on disk, so when the reader learns
|
|
||||||
// to unwrap the RIFF chunk this test starts failing — which is the
|
|
||||||
// point. Delete it then and drop the "wav" skip in
|
|
||||||
// TestFixturesMatchManifest.
|
|
||||||
func TestWAVTagsAreNotReadableYet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := testfixtures.Load(t)
|
|
||||||
|
|
||||||
for _, path := range m.Case(t, testfixtures.CaseWAVTracks) {
|
|
||||||
got, err := metadata.ExtractTags(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("extract tags from %s: %v", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.Title != "" {
|
|
||||||
t.Errorf(
|
|
||||||
"%s: WAV tags are now readable (%q) — good news; "+
|
|
||||||
"see this test's comment for what to update",
|
|
||||||
filepath.Base(path), got.Title,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertTag(t *testing.T, field string, want testfixtures.Track, got string) {
|
func assertTag(t *testing.T, field string, want testfixtures.Track, got string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user